-
- {formik.touched.name && formik.errors.name && (
-
- {formik.errors.name}
-
- )}
+
+
+
-
+
+
+
+ {!nameSaved ? (
+
+ {t("auth.saveAndContinue")}
+
+ ) : (
+
+
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")}
+
+
+ )}
+
+
+
+
);
}
diff --git a/src/app/(auth)/(register)/register/loading.tsx b/src/app/(auth)/(register)/register/loading.tsx
new file mode 100644
index 0000000..b2ac385
--- /dev/null
+++ b/src/app/(auth)/(register)/register/loading.tsx
@@ -0,0 +1,15 @@
+"use client";
+
+import IOSSpinner from "@/components/ui/IOSSpinner";
+import { useTranslation } from "react-i18next";
+
+export default function RegisterLoading() {
+ const { t } = useTranslation("common");
+
+ return (
+
+
+ {t("auth.pageLoading")}
+
+ );
+}
diff --git a/src/app/(auth)/(register)/register/page.tsx b/src/app/(auth)/(register)/register/page.tsx
index d0fed7f..25d3c11 100644
--- a/src/app/(auth)/(register)/register/page.tsx
+++ b/src/app/(auth)/(register)/register/page.tsx
@@ -5,28 +5,34 @@ import AuthHead from "@/components/auth/AuthHead";
import AuthInput from "@/components/auth/AuthInput";
import Container from "@/components/elements/Container";
import AuthRules from "@/components/auth/AuthRules";
-import React, { useState } from "react";
+import React, { useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import { markOtpSent } from "@/lib/auth/otpTimer";
-import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
+import { GoogleSignInButton } from "@/app/(auth)/AuthProviders";
import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps";
-
-// Validation schema using Yup
-const schema = yup.object({
- mobile: yup
- .string()
- .matches(/^(09\d{9})$/, "شماره موبایل معتبر نیست")
- .required("شماره موبایل الزامی است"),
-});
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
function Register() {
+ const { t } = useTranslation("common");
const router = useRouter();
const [isModalOpen, setModalOpen] = useState(false);
const { request, loading } = useAxios();
+ const schema = useMemo(
+ () =>
+ yup.object({
+ mobile: yup
+ .string()
+ .matches(/^(09\d{9})$/, t("auth.mobileInvalid"))
+ .required(t("auth.mobileRequired")),
+ }),
+ [t]
+ );
+
const formik = useFormik({
initialValues: {
mobile: "",
@@ -45,46 +51,53 @@ function Register() {
});
return (
-
-
+
+
);
}
diff --git a/src/app/(auth)/(register)/register/password/page.tsx b/src/app/(auth)/(register)/register/password/page.tsx
index 5309c1e..f2be62a 100644
--- a/src/app/(auth)/(register)/register/password/page.tsx
+++ b/src/app/(auth)/(register)/register/password/page.tsx
@@ -7,7 +7,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
-import React from "react";
+import React, { useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
@@ -15,29 +15,33 @@ import { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
-
-const schema = yup.object().shape({
- password: yup
- .string()
- .required("کلمه عبور نمی\u200Cتواند خالی باشد")
- .min(8, "کلمه عبور نباید کوتاه تر از 8 کاراکتر باشد")
- .matches(
- /^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/,
- "کلمه عبور باید شامل حروف و اعداد باشد"
- ),
- confirmPassword: yup
- .string()
- .required("تکرار کلمه عبور نمی\u200Cتواند خالی باشد")
- .oneOf(
- [yup.ref("password")],
- "تکرار کلمه عبور باید با کلمه عبور مطابقت داشته باشد"
- ),
-});
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
function PasswordPage() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ password: yup
+ .string()
+ .required(t("auth.passwordRequired"))
+ .min(8, t("auth.passwordMin"))
+ .matches(
+ /^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/,
+ t("auth.passwordLettersNumbers")
+ ),
+ confirmPassword: yup
+ .string()
+ .required(t("auth.confirmPasswordRequired"))
+ .oneOf([yup.ref("password")], t("auth.confirmPasswordMismatch")),
+ }),
+ [t]
+ );
+
const formik = useFormik({
initialValues: {
password: "",
@@ -52,68 +56,71 @@ function PasswordPage() {
router.push("/register/fullname");
} catch (err: any) {
const message =
- err?.response?.data?.message || "ذخیره کلمه عبور ناموفق بود";
+ err?.response?.data?.message || t("auth.savePasswordFailed");
toast.error(message);
}
},
});
return (
-
-
-
-
-
-
- {formik.touched.password && formik.errors.password && (
-
- {formik.errors.password}
-
- )}
+
+
+
+
+
+
+
+ {formik.touched.password && formik.errors.password && (
+
+ {formik.errors.password}
+
+ )}
-
- {formik.touched.confirmPassword && formik.errors.confirmPassword && (
-
- {formik.errors.confirmPassword}
-
- )}
-
-
+
+ {formik.touched.confirmPassword &&
+ formik.errors.confirmPassword && (
+
+ {formik.errors.confirmPassword}
+
+ )}
+
+
-
-
- ثبت و ادامه
-
-
-
-
+
+
+ {t("auth.saveAndContinue")}
+
+
+
+
+
);
}
diff --git a/src/app/(auth)/(register)/register/username/page.tsx b/src/app/(auth)/(register)/register/username/page.tsx
index b78d2d3..a478ac5 100644
--- a/src/app/(auth)/(register)/register/username/page.tsx
+++ b/src/app/(auth)/(register)/register/username/page.tsx
@@ -7,21 +7,25 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
-import React, { useState } from "react";
+import React, { useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
+import * as yup from "yup";
import { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
import AuthNextButton from "@/components/auth/AuthNextButton";
import UsernameSuggestions from "@/components/auth/UsernameSuggestions";
-import { usernameFormSchema } from "@/lib/validation/usernameSchema";
import {
sanitizeUsernameInput,
USERNAME_MIN_LENGTH,
- USERNAME_VALIDATION_MESSAGE,
+ USERNAME_MAX_LENGTH,
+ USERNAME_REGEX,
} from "@/lib/validation/username";
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
function UsernamePage() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const [isDuplicate, setIsDuplicate] = useState(false);
@@ -29,11 +33,31 @@ function UsernamePage() {
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
+ const schema = useMemo(
+ () =>
+ yup.object({
+ username: yup
+ .string()
+ .required(t("auth.usernameRequired"))
+ .min(
+ USERNAME_MIN_LENGTH,
+ t("auth.usernameMinLength", { min: USERNAME_MIN_LENGTH })
+ )
+ .max(
+ USERNAME_MAX_LENGTH,
+ t("auth.usernameMaxLength", { max: USERNAME_MAX_LENGTH })
+ )
+ .matches(/^[a-zA-Z0-9]/, t("auth.usernameStartRule"))
+ .matches(USERNAME_REGEX, t("auth.usernameCharsRule")),
+ }),
+ [t]
+ );
+
const formik = useFormik({
initialValues: {
username: "",
},
- validationSchema: usernameFormSchema,
+ validationSchema: schema,
onSubmit: async (values) => {
setIsDuplicate(false);
setSuggestions([]);
@@ -45,7 +69,13 @@ function UsernamePage() {
}) as IVerifyOtp;
localStorage.setItem("username", values.username.trim().toLowerCase());
- router.push("/register/password");
+ const authProvider =
+ typeof window !== "undefined"
+ ? localStorage.getItem("auth_provider")
+ : null;
+ router.push(
+ authProvider === "google" ? "/register/fullname" : "/register/password"
+ );
} catch (err: any) {
const data = err?.response?.data;
if (data?.message?.includes("تکراری")) {
@@ -72,73 +102,81 @@ function UsernamePage() {
};
return (
-
-
-
-
-
-
- حداقل {USERNAME_MIN_LENGTH} کاراکتر؛ فقط حروف انگلیسی، اعداد و
- . _ -
-
-
handleUsernameChange(e.target.value)}
- onBlur={formik.handleBlur}
- />
+
+
+
+
+
+
+
+ {t("auth.usernameHint", {
+ max: USERNAME_MAX_LENGTH,
+ min: USERNAME_MIN_LENGTH,
+ })}
+
+
handleUsernameChange(e.target.value)}
+ onBlur={formik.handleBlur}
+ />
- {isDuplicate && (
-
- نام کاربری تکراری است
-
- )}
+ {isDuplicate && (
+
+ {t("auth.usernameDuplicate")}
+
+ )}
- {formik.touched.username && formik.errors.username && !isDuplicate && (
-
- {formik.errors.username}
-
- )}
+ {formik.touched.username &&
+ formik.errors.username &&
+ !isDuplicate && (
+
+ {formik.errors.username}
+
+ )}
- {!formik.errors.username && !isDuplicate && (
-
- {USERNAME_VALIDATION_MESSAGE}
-
- )}
+ {!formik.errors.username && !isDuplicate && (
+
+ {t("auth.usernameCharsRule")}
+
+ )}
-
-
-
+
+
+
-
-
- ثبت و ادامه
-
-
-
-
+
+
+ {t("auth.saveAndContinue")}
+
+
+
+
+
);
}
diff --git a/src/app/(auth)/AuthProviders.tsx b/src/app/(auth)/AuthProviders.tsx
index 7ff1ce2..7e82c7f 100644
--- a/src/app/(auth)/AuthProviders.tsx
+++ b/src/app/(auth)/AuthProviders.tsx
@@ -1,16 +1,72 @@
"use client";
-import { GoogleAuthRootProvider } from "@/components/auth/GoogleSignInButton";
+import GoogleSignInButtonBase, {
+ GoogleAuthRootProvider,
+} from "@/components/auth/GoogleSignInButton";
import RegisterRouteGuard from "@/components/auth/RegisterRouteGuard";
+import { GOOGLE_OAUTH_CLIENT_ID } from "@/config/googleAuth";
+import { createContext, useContext, useEffect, useState } from "react";
+
+const GoogleClientIdContext = createContext("");
+
+export function useGoogleClientId(): string {
+ return useContext(GoogleClientIdContext);
+}
+
+export function GoogleSignInButton(
+ props: Omit
, "clientId">
+) {
+ const clientId = useGoogleClientId();
+ return (
+
+ );
+}
export default function AuthProviders({
children,
+ googleClientId = "",
}: {
children: React.ReactNode;
+ googleClientId?: string;
}) {
+ const [resolvedClientId, setResolvedClientId] = useState(
+ googleClientId || GOOGLE_OAUTH_CLIENT_ID
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function loadClientId() {
+ const fallback = googleClientId || GOOGLE_OAUTH_CLIENT_ID;
+ setResolvedClientId(fallback);
+
+ try {
+ const res = await fetch("/api/auth/config", { cache: "no-store" });
+ if (!res.ok) return;
+ const data = (await res.json()) as { googleClientId?: string };
+ if (!cancelled) {
+ setResolvedClientId(data?.googleClientId || fallback);
+ }
+ } catch {
+ // keep fallback
+ }
+ }
+
+ void loadClientId();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [googleClientId]);
+
return (
-
- {children}
-
+
+
+ {children}
+
+
);
}
diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx
index d9a1192..7594a1b 100644
--- a/src/app/(auth)/layout.tsx
+++ b/src/app/(auth)/layout.tsx
@@ -1,18 +1,22 @@
import type { Metadata } from "next";
import AuthProviders from "./AuthProviders";
+import { getGoogleClientId } from "@/lib/auth/googleClientId";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
-export const metadata: Metadata = {
- title: {
- default: "ورود و ثبتنام | مدستاگرام",
- },
- description: "ورود یا ثبتنام در مدستاگرام",
- robots: { index: false, follow: false },
-};
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata(): Promise {
+ return generateSeoPageMetadata("auth", { index: false });
+}
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
- return {children} ;
+ const googleClientId = getGoogleClientId();
+
+ return (
+ {children}
+ );
}
diff --git a/src/app/(auth)/verify/auth/page.tsx b/src/app/(auth)/verify/auth/page.tsx
index cfb56a1..dcbff1a 100644
--- a/src/app/(auth)/verify/auth/page.tsx
+++ b/src/app/(auth)/verify/auth/page.tsx
@@ -5,7 +5,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
-import React from "react";
+import React, { useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
@@ -19,30 +19,38 @@ import persian from "react-date-object/calendars/persian";
import persian_fa from "react-date-object/locales/persian_fa";
import Image from "next/image";
import moment from "jalali-moment";
-
-// Validation schema using Yup
-const schema = yup.object().shape({
- birthDate: yup.string().required(" انتخاب تاریخ تولد الزامی است"),
- shaba: yup
- .string()
- .matches(/^(?=.{24}$)[0-9]*$/, "شماره شبا باید ۲۴ رقم باشد")
- .required("شماره شبا الزامی است"),
- nationalCode: yup
- .string()
- .matches(/^\d{10}$/, "کد ملی باید ۱۰ رقم باشد")
- .required("کد ملی الزامی است"),
-});
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
function AuthPage() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
-
+
const p2e = (es: string): string =>
- es.replace(/[۰-۹]/g, (d) => String.fromCharCode("۰۱۲۳۴۵۶۷۸۹".indexOf(d) + 48));
-
+ es.replace(/[۰-۹]/g, (d) =>
+ String.fromCharCode("۰۱۲۳۴۵۶۷۸۹".indexOf(d) + 48)
+ );
+
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ birthDate: yup.string().required(t("auth.birthDateRequired")),
+ shaba: yup
+ .string()
+ .matches(/^(?=.{24}$)[0-9]*$/, t("auth.shabaInvalid"))
+ .required(t("auth.shabaRequired")),
+ nationalCode: yup
+ .string()
+ .matches(/^\d{10}$/, t("auth.nationalCodeInvalid"))
+ .required(t("auth.nationalCodeRequired")),
+ }),
+ [t]
+ );
+
const formik = useFormik({
initialValues: {
nationalCode: "",
@@ -56,129 +64,128 @@ function AuthPage() {
mobile: mobile,
birthday: moment(p2e(values.birthDate), "jYYYY/jMM/jDD").format(
"YYYY/MM/DD"
- ), // تبدیل به میلادی
+ ),
national_code: values.nationalCode,
shaba: values.shaba,
});
router.push("/verify/location");
} catch (err: any) {
- toast.error(err?.message || "خطای نامشخصی رخ داد.");
+ toast.error(err?.response?.data?.message || t("auth.unknownError"));
} finally {
setSubmitting(false);
}
},
});
- console.log(formik.values);
-
return (
-
-
-
- احراز هویت
-
-
-
-
جهت احراز هویت
-
-
- {/* کد ملی */}
-
+
+
+
+
+
+ {t("auth.identityVerification")}
+
+
- {/* تاریخ تولد */}
-
- {
- const formattedDate = value?.format("YYYY/MM/DD") || ""; // اگر مقدار `null` بود، رشته خالی
- formik.setFieldValue("birthDate", formattedDate); // مقدار فرم را تنظیم کنید
- }}
- calendarPosition="bottom-center"
- placeholder="تاریخ تولد"
- inputClass={`w-full dir-ltr max-w-[290px] p-3 !pr-6 text-center rounded-2xl border border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium border mt-2 !p-1 h-[36px] ${
- formik.touched.birthDate && formik.errors.birthDate
- ? "border-red-500 dark:border-red-500"
- : "border-gray-300"
- }`}
- />
-
-
-
-
-
- {formik.touched.nationalCode && formik.errors.nationalCode && (
-
- {formik.errors.nationalCode}
-
- )}
- {formik.touched.birthDate && formik.errors.birthDate && (
-
- {formik.errors.birthDate}
-
- )}
-
جهت واریز حق الزحمه
- {/* شماره شبا */}
-
-
IR
-
- {formik.touched.shaba && formik.errors.shaba && (
-
- {formik.errors.shaba}
+
+
+ {t("auth.forIdentityVerification")}
- )}
-
+
+
+
+ {
+ const formattedDate = value?.format("YYYY/MM/DD") || "";
+ formik.setFieldValue("birthDate", formattedDate);
+ }}
+ calendarPosition="bottom-center"
+ placeholder={t("auth.birthDatePlaceholder")}
+ inputClass={`w-full dir-ltr max-w-[290px] p-3 !pr-6 text-center rounded-2xl border border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium border mt-2 !p-1 h-[36px] ${
+ formik.touched.birthDate && formik.errors.birthDate
+ ? "border-red-500 dark:border-red-500"
+ : "border-gray-300"
+ }`}
+ />
+
+
+
+
+
-
- شماره شبا باید به نام خود شخص باشد
-
-
-
+ {formik.touched.nationalCode && formik.errors.nationalCode && (
+
+ {formik.errors.nationalCode}
+
+ )}
+ {formik.touched.birthDate && formik.errors.birthDate && (
+
+ {formik.errors.birthDate}
+
+ )}
+
{t("auth.forPayment")}
+
+
IR
+
+ {formik.touched.shaba && formik.errors.shaba && (
+
+ {formik.errors.shaba}
+
+ )}
+
-
router.push("/verify/location")}>
-
- ثبت و ادامه
-
-
-
-
+
{t("auth.shabaOwnerHint")}
+
+
+
+
router.push("/verify/location")}>
+
+ {t("auth.saveAndContinue")}
+
+
+
+
+
);
}
diff --git a/src/app/(auth)/verify/avatar/page.tsx b/src/app/(auth)/verify/avatar/page.tsx
index 040e37c..0d0f3e7 100644
--- a/src/app/(auth)/verify/avatar/page.tsx
+++ b/src/app/(auth)/verify/avatar/page.tsx
@@ -6,19 +6,20 @@ import AuthPageLayout, {
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
-import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
+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";
-
-// کتابخانه برای crop
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";
function AvatarPage() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const [userDetail, setUserDetail] = useState
(null);
@@ -45,7 +46,6 @@ function AvatarPage() {
fetchData();
}, []);
- // انتخاب تصویر
const selectImage = async (event: React.ChangeEvent) => {
if (event.target.files && event.target.files[0]) {
const file = event.target.files[0];
@@ -58,37 +58,28 @@ function AvatarPage() {
}
};
- // حذف تصویر
- // const removeImage = () => {
- // setAvatar(null);
- // setOriginalImage(null);
- // };
-
- // دریافت مختصات crop شده
const onCropComplete = useCallback(
- (croppedArea: Area, croppedPixels: Area) => {
+ (_croppedArea: Area, croppedPixels: Area) => {
setCroppedAreaPixels(croppedPixels);
},
[]
);
- // تبدیل crop شده به blob
const createCroppedImage = async () => {
if (!avatar || !croppedAreaPixels) return null;
const croppedImage = await getCroppedImg(avatar, croppedAreaPixels);
return croppedImage;
};
- // آپلود تصویر
const uploadImage = async () => {
if (!avatar) {
- toast.error("لطفا تصویر پروفایل را انتخاب کنید");
+ toast.error(t("auth.selectProfileImage"));
return;
}
setLoadingUpload(true);
const croppedBlob = await createCroppedImage();
if (!croppedBlob) {
- toast.error("خطا در برش تصویر");
+ toast.error(t("auth.cropError"));
setLoadingUpload(false);
return;
}
@@ -101,16 +92,15 @@ function AvatarPage() {
await request("POST", "/verify/profile_image", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
- toast.success("تصویر با موفقیت ثبت شد!");
+ toast.success(t("auth.imageSavedSuccess"));
navigateHandler();
} catch (err) {
console.log("Upload error:", err);
- toast.error("خطا در ارسال تصویر!");
+ toast.error(t("auth.imageUploadError"));
setLoadingUpload(false);
}
};
- // ناوبری بعد از آپلود
const navigateHandler = () => {
if (userDetail?.user_type === "user") {
router.push("/verify/expertise");
@@ -120,68 +110,71 @@ function AvatarPage() {
};
return (
-
-
- تصویر پروفایل
+
+
+
+
+ {t("auth.profileImageTitle")}
+
-
- {avatar ? (
-
+ {avatar ? (
+
+ ) : (
+
+
- ) : (
-
-
-
- )}
-
+
+ )}
+
-
+
-
-
- انتخاب/ویرایش تصویر
-
-
-
+
+
+ {t("auth.selectEditImage")}
+
+
+
-
-
- ثبت و ادامه
-
-
-
+
+
+ {t("auth.saveAndContinue")}
+
+
+
+
);
}
-export default AvatarPage;
\ No newline at end of file
+export default AvatarPage;
diff --git a/src/app/(auth)/verify/colors/page.tsx b/src/app/(auth)/verify/colors/page.tsx
index f71d675..4c5888d 100644
--- a/src/app/(auth)/verify/colors/page.tsx
+++ b/src/app/(auth)/verify/colors/page.tsx
@@ -5,7 +5,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import * as yup from "yup";
import { useRouter } from "next/navigation";
@@ -13,15 +13,12 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
import Image from "next/image";
-
-// Validation schema using Yup
-const schema = yup.object().shape({
- selectedHairImage: yup.string().required("رنگ مو الزامی است"),
- selectedEyeImage: yup.string().required(" رنگ چشم الزامی است"),
-});
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
function Colors() {
- const [selectedButton, setSelectedButton] = useState(1); // Default: 1 for "قد"
+ const { t } = useTranslation("common");
+ const [selectedButton, setSelectedButton] = useState(1);
const [selectedHairImage, setSelectedHairImage] = useState("");
const [selectedEyeImage, setSelectedEyeImage] = useState("");
const router = useRouter();
@@ -32,6 +29,15 @@ function Colors() {
typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : ""
);
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ selectedHairImage: yup.string().required(t("auth.hairColorRequired")),
+ selectedEyeImage: yup.string().required(t("auth.eyeColorRequired")),
+ }),
+ [t]
+ );
+
useEffect(() => {
if (expertise && expertise !== "مدل") {
router.replace("/verify/public-relations");
@@ -49,8 +55,6 @@ function Colors() {
}
};
- console.log(selectedHairImage, selectedEyeImage);
-
const sendSizesHandler = async () => {
try {
const data = {
@@ -69,7 +73,6 @@ function Colors() {
setSelectedButton(buttonIndex);
};
- // تصاویر رنگ چشم و رنگ مو
const eyeImages = Array.from(
{ length: 26 },
(_, i) => `/images/eyes/${String(i + 1).padStart(2, "0")}.png`
@@ -79,9 +82,8 @@ function Colors() {
(_, i) => `/images/hairs/${String(i + 1).padStart(2, "0")}.png`
);
- // تابع برای ذخیره نام فایل انتخاب شده
const handleImageSelect = (image: string, type: "eye" | "hair") => {
- const fileName = image.split("/").pop(); // استخراج فقط نام فایل
+ const fileName = image.split("/").pop();
if (type === "eye") {
setSelectedEyeImage(fileName || "");
} else if (type === "hair") {
@@ -89,98 +91,97 @@ function Colors() {
}
};
-
-
-
return (
-
-
- سایز
-
+
+
+
+ {t("auth.sizesTitle")}
+
- مشخصات ظاهری
+
+ {t("auth.appearanceDetails")}
+
- {/* Buttons for categories */}
-
-
handleButtonPress(1)}
+
+ handleButtonPress(1)}
+ >
+ {t("auth.eyeColor")}
+
+ handleButtonPress(2)}
+ >
+ {t("auth.hairColor")}
+
+
+
+
+ {selectedButton === 1 && (
+
+ {eyeImages.map((image) => (
+ handleImageSelect(image, "eye")}
+ />
+ ))}
+
+ )}
+
+ {selectedButton === 2 && (
+
+ {hairImages.map((image) => (
+ handleImageSelect(image, "hair")}
+ />
+ ))}
+
+ )}
+
+
+
+ router.push("/verify/public-relations")}>
+
- رنگ چشم
-
-
handleButtonPress(2)}
- >
- رنگ مو
-
-
-
- {/* Display images based on selected button */}
-
- {selectedButton === 1 && (
-
- {eyeImages.map((image) => (
- handleImageSelect(image, "eye")}
- />
- ))}
-
- )}
-
- {selectedButton === 2 && (
-
- {hairImages.map((image) => (
- handleImageSelect(image, "hair")}
- />
- ))}
-
- )}
-
-
-
- router.push("/verify/public-relations")}>
-
- ثبت و ادامه
-
-
-
+ {t("auth.saveAndContinue")}
+
+
+
+
);
}
diff --git a/src/app/(auth)/verify/confirm/page.tsx b/src/app/(auth)/verify/confirm/page.tsx
index 49e12d5..1804a9b 100644
--- a/src/app/(auth)/verify/confirm/page.tsx
+++ b/src/app/(auth)/verify/confirm/page.tsx
@@ -12,14 +12,16 @@ import VerificationBadge from "@/components/main/VerificationBadge";
import { useUser } from "@/hooks/useUser";
import { formatFullName } from "@/lib/formatFullName";
import useAxios from "@/hooks/useAxios";
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
function Confirm() {
+ const { t } = useTranslation("common");
const user = useUser();
const { request } = useAxios();
const router = useRouter();
const [showModal, setShowModal] = useState(false);
const [navigating, setNavigating] = useState(false);
-
const [verifiedStatus, setVerifiedStatus] = useState(null);
useEffect(() => {
@@ -43,114 +45,122 @@ function Confirm() {
setShowModal(true);
setNavigating(false);
};
+
return (
-
-
-
- ثبت نام شما تکمیل شد
-
-
- ورود شما به خانواده مدستاگرام را تبریک می گوییم{" "}
-
-
-
-
-
{formatFullName(user?.first_name, user?.last_name)}
-
-
- {user?.user_name}
-
-
+
+
+
+
+ {t("auth.registrationComplete")}
+
+
+ {t("auth.welcomeMessage")}
+
+
+
+
+
{formatFullName(user?.first_name, user?.last_name)}
+
+
+ {user?.user_name}
+
+
+
-
-
- پس از تایید مدارک و احراز هویت، تیک آبی یا طلایی کنار نام کاربری شما
- قرار می گیرد
-
+
+ {t("auth.verificationBadgeHint")}
+
-
-
-
-
-
-
-
+
+
+ {t("auth.saveAndContinue")}
+
+
+ {t("auth.verificationTimeOffice")}
+
+
+ {t("auth.verificationTimeOffHours")}
+
+
setShowModal(false)}
+ height="200px"
+ >
+
+ {t("auth.shareWorkHint")}
+
+
+
+ {t("auth.createPost")}
+
+
+ {t("auth.later")}
+
+
+
- {/* */}
-
- ثبت و ادامه
-
-
- زمان تایید مدارک 5 دقیقه تا 1 ساعت در ساعات اداری و
-
-
- 3 تا 8 ساعت در ساعات غیر اداری
-
-
setShowModal(false)}
- height="200px"
- >
-
- .برای بهتر دیده شدن نمونه کارهای خود را به اشتراگ بگذراید
-
-
-
- ثبت پست
-
-
- بعدا
-
-
-
-
-
+
+
);
}
diff --git a/src/app/(auth)/verify/cooperation-type/page.tsx b/src/app/(auth)/verify/cooperation-type/page.tsx
index 99663dc..a772a24 100644
--- a/src/app/(auth)/verify/cooperation-type/page.tsx
+++ b/src/app/(auth)/verify/cooperation-type/page.tsx
@@ -5,7 +5,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
-import React from "react";
+import React, { useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
@@ -13,18 +13,24 @@ import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
-
-// Validation schema using Yup
-const schema = yup.object().shape({
- selectedButton: yup.boolean().required("انتخاب نوع همکاری الزامی است"),
-});
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
function CooperationType() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ selectedButton: yup.boolean().required(t("auth.cooperationRequired")),
+ }),
+ [t]
+ );
+
const formik = useFormik({
initialValues: {
selectedButton: null,
@@ -39,7 +45,7 @@ function CooperationType() {
});
router.push("/verify/public-relations");
} catch (err: any) {
- toast.error(err?.message || "خطای نامشخصی رخ داد.");
+ toast.error(err?.response?.data?.message || t("auth.unknownError"));
} finally {
setSubmitting(false);
}
@@ -47,62 +53,66 @@ function CooperationType() {
});
return (
-
-
-
- نوع همکاری
-
-
-
- آیا مایل به همکاری خارج از محل سکونت خود هستید؟
-
-
-
-
-
formik.setFieldValue("selectedButton", true)}
- className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
- formik.values.selectedButton === true
- ? "btn-modern--selected text-white"
- : ""
- }`}
- >
- بله خوشحال هم می شم
-
-
formik.setFieldValue("selectedButton", false)}
- className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
- formik.values.selectedButton === false
- ? "btn-modern--selected text-white"
- : ""
- }`}
- >
- نه شهر خودم رو ترجیح میدم
-
-
-
- {formik.errors.selectedButton && formik.touched.selectedButton && (
-
- {formik.errors.selectedButton}
+
+
+
+
+
+ {t("auth.cooperationTypeTitle")}
+
+
- )}
-
-
-
router.push("/verify/public-relations")}>
-
- ثبت و ادامه
-
-
-
-
+
+ {t("auth.cooperationQuestion")}
+
+
+
+
+
formik.setFieldValue("selectedButton", true)}
+ className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
+ formik.values.selectedButton === true
+ ? "btn-modern--selected text-white"
+ : ""
+ }`}
+ >
+ {t("auth.cooperationYes")}
+
+
formik.setFieldValue("selectedButton", false)}
+ className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
+ formik.values.selectedButton === false
+ ? "btn-modern--selected text-white"
+ : ""
+ }`}
+ >
+ {t("auth.cooperationNo")}
+
+
+
+ {formik.errors.selectedButton && formik.touched.selectedButton && (
+
+ {formik.errors.selectedButton}
+
+ )}
+
+
+
+
router.push("/verify/public-relations")}>
+
+ {t("auth.saveAndContinue")}
+
+
+
+
+
);
}
diff --git a/src/app/(auth)/verify/expertise/page.tsx b/src/app/(auth)/verify/expertise/page.tsx
index b17a8bb..4842147 100644
--- a/src/app/(auth)/verify/expertise/page.tsx
+++ b/src/app/(auth)/verify/expertise/page.tsx
@@ -5,7 +5,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import * as yup from "yup";
import { useRouter } from "next/navigation";
@@ -14,24 +14,42 @@ import toast from "react-hot-toast";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
import ExpertisePicker from "@/components/auth/ExpertisePicker";
import { IExpertise } from "@/types/types";
-
-const schema = yup.object().shape({
- subExpertise: yup
- .array()
- .of(yup.string().required("حداقل یک زیرمهارت را انتخاب کنید"))
- .min(1, "حداقل یک زیرمهارت را انتخاب کنید"),
- expertise: yup.string().required("انتخاب نوع تخصص الزامی است"),
-});
+import {
+ resolveDisplaySubExpertise,
+ subExpertiseListIncludes,
+} from "@/lib/subExpertiseDisplay";
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
function Expertise() {
+ const { t } = useTranslation("common");
const [expertise, setExpertise] = useState
("");
const [expertiseList, setExpertiseList] = useState(null);
const [subExpertise, setSubExpertise] = useState([]);
+ const [displaySubExpertise, setDisplaySubExpertise] = useState(
+ null
+ );
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ subExpertise: yup
+ .array()
+ .of(yup.string().required(t("auth.subExpertiseMin")))
+ .min(1, t("auth.subExpertiseMin")),
+ displaySubExpertise: yup
+ .string()
+ .nullable()
+ .required(t("auth.displaySubExpertiseRequired")),
+ expertise: yup.string().required(t("auth.expertiseRequired")),
+ }),
+ [t]
+ );
+
const fetchData = async () => {
try {
const response = await request<{ expertises: IExpertise[] }>(
@@ -48,10 +66,44 @@ function Expertise() {
fetchData();
}, []);
+ const handleSubExpertiseToggle = (value: string) => {
+ setSubExpertise((prev) => {
+ const next = prev.includes(value)
+ ? prev.filter((item) => item !== value)
+ : [...prev, value];
+
+ setDisplaySubExpertise((current) => {
+ if (current === value && !next.includes(value)) {
+ return next[0] ?? null;
+ }
+ if (current && !next.includes(current)) {
+ return next[0] ?? null;
+ }
+ if (!current && next.length === 1) {
+ return next[0];
+ }
+ return current;
+ });
+
+ return next;
+ });
+ };
+
const handleCheck = async () => {
+ const resolvedDisplay = subExpertiseListIncludes(
+ subExpertise,
+ displaySubExpertise
+ )
+ ? displaySubExpertise
+ : resolveDisplaySubExpertise(subExpertise, displaySubExpertise);
+
try {
- await schema.validate({ expertise, subExpertise });
- await sendExpertiseTypeHandler();
+ await schema.validate({
+ expertise,
+ subExpertise,
+ displaySubExpertise: resolvedDisplay,
+ });
+ await sendExpertiseTypeHandler(resolvedDisplay);
} catch (validationError: any) {
toast.error(validationError.message, {
duration: 4000,
@@ -59,59 +111,62 @@ function Expertise() {
}
};
- const sendExpertiseTypeHandler = async () => {
+ const sendExpertiseTypeHandler = async (resolvedDisplay: string | null) => {
try {
const data = {
mobile,
expertise,
sub_expertise: subExpertise,
+ display_sub_expertise: resolvedDisplay,
};
await request("POST", "/verify/expertise", data);
localStorage.setItem("expertise", expertise);
+ if (resolvedDisplay) {
+ localStorage.setItem("display_sub_expertise", resolvedDisplay);
+ }
router.push("/verify/services");
} catch (error: any) {
- toast.error(error?.message || "خطای نامشخصی رخ داد.");
+ toast.error(error?.response?.data?.message || t("auth.unknownError"));
}
};
return (
-
-
- تخصص
+
+
+
+ {t("auth.expertiseTitle")}
-
+
- در چه زمینه ای تخصص دارید؟
+ {t("auth.expertiseQuestion")}
- {
- setExpertise(value);
- setSubExpertise([]);
- }}
- onSubExpertiseToggle={(value) => {
- setSubExpertise((prev) =>
- prev.includes(value)
- ? prev.filter((item) => item !== value)
- : [...prev, value]
- );
- }}
- />
-
+ {
+ setExpertise(value);
+ setSubExpertise([]);
+ setDisplaySubExpertise(null);
+ }}
+ onSubExpertiseToggle={handleSubExpertiseToggle}
+ onDisplaySubExpertiseChange={setDisplaySubExpertise}
+ />
+
- router.push("/verify/services")}>
-
- ثبت و ادامه
-
-
-
+ router.push("/verify/services")}>
+
+ {t("auth.saveAndContinue")}
+
+
+
+
);
}
diff --git a/src/app/(auth)/verify/gender/page.tsx b/src/app/(auth)/verify/gender/page.tsx
index 4c67b61..b699bd0 100644
--- a/src/app/(auth)/verify/gender/page.tsx
+++ b/src/app/(auth)/verify/gender/page.tsx
@@ -5,7 +5,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
-import React from "react";
+import React, { useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
@@ -14,13 +14,11 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
import Image from "next/image";
-
-// Validation schema using Yup
-const schema = yup.object().shape({
- selectedButton: yup.string().required("جنسیت الزامی است"),
-});
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
function Gender() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
@@ -28,6 +26,14 @@ function Gender() {
const expertise =
typeof window !== "undefined" ? localStorage.getItem("expertise") : null;
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ selectedButton: yup.string().required(t("auth.genderRequired")),
+ }),
+ [t]
+ );
+
const formik = useFormik({
initialValues: {
selectedButton: "",
@@ -45,7 +51,7 @@ function Gender() {
router.push("/verify/services");
}
} catch (err: any) {
- toast.error(err?.message || "خطای نامشخصی رخ داد.");
+ toast.error(err?.response?.data?.message || t("auth.unknownError"));
} finally {
setSubmitting(false);
}
@@ -53,70 +59,72 @@ function Gender() {
});
return (
-
-
-
- جنسیت
-
-
-
-
-
formik.setFieldValue("selectedButton", "female")}
- className={`mt-5 w-full max-w-[250px] !border-[#FC8EAC] flex items-center justify-center flex-row-reverse gap-2 ${
- formik.values.selectedButton === "female"
- ? "btn-modern--selected text-white"
- : ""
- }`}
- >
-
- خانم
-
-
formik.setFieldValue("selectedButton", "male")}
- className={`mt-5 w-full max-w-[250px] !border-[#3E8BFF] flex items-center justify-center flex-row-reverse gap-2 ${
- formik.values.selectedButton === "male"
- ? "!bg-[#3E8BFF] !text-white"
- : ""
- }`}
- >
-
- آقا
-
-
-
- {formik.errors.selectedButton && formik.touched.selectedButton && (
-
- {formik.errors.selectedButton}
+
+
+
+
+ {t("auth.genderTitle")}
+
- )}
-
-
-
router.push("/verify/sizes")}>
-
- ثبت و ادامه
-
-
-
-
+
+
+
formik.setFieldValue("selectedButton", "female")}
+ className={`mt-5 w-full max-w-[250px] !border-[#FC8EAC] flex items-center justify-center flex-row-reverse gap-2 ${
+ formik.values.selectedButton === "female"
+ ? "btn-modern--selected text-white"
+ : ""
+ }`}
+ >
+
+ {t("auth.female")}
+
+
formik.setFieldValue("selectedButton", "male")}
+ className={`mt-5 w-full max-w-[250px] !border-[#3E8BFF] flex items-center justify-center flex-row-reverse gap-2 ${
+ formik.values.selectedButton === "male"
+ ? "!bg-[#3E8BFF] !text-white"
+ : ""
+ }`}
+ >
+
+ {t("auth.male")}
+
+
+
+ {formik.errors.selectedButton && formik.touched.selectedButton && (
+
+ {formik.errors.selectedButton}
+
+ )}
+
+
+
+
router.push("/verify/sizes")}>
+
+ {t("auth.saveAndContinue")}
+
+
+
+
+
);
}
diff --git a/src/app/(auth)/verify/location/page.tsx b/src/app/(auth)/verify/location/page.tsx
index c5bfbdd..0c2f79a 100644
--- a/src/app/(auth)/verify/location/page.tsx
+++ b/src/app/(auth)/verify/location/page.tsx
@@ -5,7 +5,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
@@ -16,19 +16,13 @@ import AuthUserDetails from "@/components/auth/AuthUserDetails";
import SelectBox from "@/components/elements/SelectBox";
import { ICity, IProvince } from "@/types/types";
import Map, { GeolocateControl, Marker } from "react-map-gl";
-
import "mapbox-gl/dist/mapbox-gl.css";
import Image from "next/image";
-
-// Validation schema using Yup
-const schema = yup.object().shape({
- markerCoordinate: yup.mixed().required(" انتخاب لوکیشن الزامی است"),
- address: yup.string().required(" آدرس الزامی است"),
- cityId: yup.string().required(" انتخاب شهر الزامی است"),
- stateId: yup.string().required(" انتخاب استان الزامی است"),
-});
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
function LocationPage() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
@@ -40,6 +34,17 @@ function LocationPage() {
lng: number;
} | null>(null);
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ markerCoordinate: yup.mixed().required(t("auth.locationRequired")),
+ address: yup.string().required(t("auth.addressRequired")),
+ cityId: yup.string().required(t("auth.cityRequired")),
+ stateId: yup.string().required(t("auth.provinceRequired")),
+ }),
+ [t]
+ );
+
const fetchStates = async () => {
try {
const response = await request<{ provinces: IProvince[] }>(
@@ -52,7 +57,6 @@ function LocationPage() {
}
};
- // Fetch cities when a province is selected
const fetchCities = async (provinceId: string) => {
try {
const response = await request<{ cities: ICity[] }>(
@@ -97,18 +101,17 @@ function LocationPage() {
});
router.push("/verify/national-cart");
} catch (err: any) {
- toast.error(err?.message || "خطای نامشخصی رخ داد.");
+ toast.error(err?.response?.data?.message || t("auth.unknownError"));
} finally {
setSubmitting(false);
}
},
});
- // Handle province change
const handleProvinceChange = (e: React.ChangeEvent
) => {
const selectedProvinceId = e.target.value;
formik.setFieldValue("stateId", selectedProvinceId);
- fetchCities(selectedProvinceId); // Fetch cities for the selected province
+ fetchCities(selectedProvinceId);
};
const handleMapClick = (event: any) => {
@@ -121,135 +124,137 @@ function LocationPage() {
};
return (
-
-
-
- لوکیشن
-
+
+
+
+
+ {t("auth.locationTitle")}
+
-
- مشخص کنید در کدام شهر قادر به انجام فعالیت هستید
-
-
-
-
-
- استان
-
- {allStates?.map((item: IProvince) => (
-
- {item?.name}
-
- ))}
-
- {formik.touched.stateId && formik.errors.stateId && (
-
- {formik.errors.stateId}
+
+ {t("auth.locationActivityHint")}
- )}
- formik.setFieldValue("cityId", e.target.value)}
- >
-
- شهر
-
- {cities?.map((city: ICity) => (
-
- {city.name}
-
- ))}
-
- {formik.touched.cityId && formik.errors.cityId && (
-
- {formik.errors.cityId}
-
- )}
-
-
- {formik.touched.address && formik.errors.address && (
-
- {formik.errors.address}
-
- )}
-
-
- {selectedLocation && (
-
+
-
-
- )}
-
-
-
-
- اطلاعات لوکیشن شما برای همه قابل نمایش باشد
-
-
-
-
+
+ {t("auth.province")}
+
+ {allStates?.map((item: IProvince) => (
+
+ {item?.name}
+
+ ))}
+
+ {formik.touched.stateId && formik.errors.stateId && (
+
+ {formik.errors.stateId}
+
+ )}
- router.push("/verify/national-cart")}>
-
- ثبت و ادامه
-
-
-
-
+ formik.setFieldValue("cityId", e.target.value)}
+ >
+
+ {t("auth.city")}
+
+ {cities?.map((city: ICity) => (
+
+ {city.name}
+
+ ))}
+
+ {formik.touched.cityId && formik.errors.cityId && (
+
+ {formik.errors.cityId}
+
+ )}
+
+
+ {formik.touched.address && formik.errors.address && (
+
+ {formik.errors.address}
+
+ )}
+
+
+ {selectedLocation && (
+
+
+
+ )}
+
+
+
+
+ {t("auth.showLocationPublic")}
+
+
+
+
+
+ router.push("/verify/national-cart")}>
+
+ {t("auth.saveAndContinue")}
+
+
+
+
+
);
}
diff --git a/src/app/(auth)/verify/national-cart/page.tsx b/src/app/(auth)/verify/national-cart/page.tsx
index 11c1822..d7a620b 100644
--- a/src/app/(auth)/verify/national-cart/page.tsx
+++ b/src/app/(auth)/verify/national-cart/page.tsx
@@ -12,10 +12,13 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
import Image from "next/image";
-import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
+import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import AuthRules from "@/components/auth/AuthRules";
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
function NationalCart() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
@@ -28,7 +31,6 @@ function NationalCart() {
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
- // انتخاب عکس از دوربین یا فایل
const selectImage = async (event: React.ChangeEvent) => {
if (event.target.files && event.target.files[0]) {
const file = event.target.files[0];
@@ -38,17 +40,15 @@ function NationalCart() {
}
};
- // حذف تصویر
const removeImage = () => setNationalCartImg(null);
- // ارسال فرم
const uploadImage = async () => {
if (!nationalCartImg) {
- toast.error("لطفا تصویر کارت ملی را انتخاب کنید");
+ toast.error(t("auth.selectNationalCard"));
return;
}
if (!isCheckedOne) {
- toast.error("لطفا قوانین را مطالعه و تایید کنید");
+ toast.error(t("auth.acceptRulesRequired"));
return;
}
@@ -63,105 +63,109 @@ function NationalCart() {
await request("POST", "/verify/national_card_image", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
- toast.success("تصویر با موفقیت ارسال شد!");
+ toast.success(t("auth.nationalCardSentSuccess"));
router.push("/verify/confirm");
} catch (err) {
console.log("Upload error:", err);
- toast.error("خطا در ارسال تصویر!");
+ toast.error(t("auth.imageUploadError"));
}
};
return (
-
-
- احراز هویت
-
+
+
+
+
+ {t("auth.identityVerification")}
+
+
-
- برای ثبت درخواست، نیازمند احراز هویت شما هستیم
-
+
+ {t("auth.nationalCardVerificationDesc")}
+
-
- {nationalCartImg ? (
- <>
-
-
+ {nationalCartImg ? (
+ <>
+
+
+
+
+ >
+ ) : (
+
-
- >
- ) : (
-
-
-
- )}
-
+
+ )}
+
-
-
-
- اصل کارت ملی را بر روی سینه در دست گرفته و از خود عکس بگیرید
-
-
-
-
-
-
-
- router.push("/verify/confirm")}>
-
- ثبت و ادامه
-
-
-
+ {t("auth.saveAndContinue")}
+
+
+
+
);
}
diff --git a/src/app/(auth)/verify/public-relations/page.tsx b/src/app/(auth)/verify/public-relations/page.tsx
index 4cf115e..d5c7f70 100644
--- a/src/app/(auth)/verify/public-relations/page.tsx
+++ b/src/app/(auth)/verify/public-relations/page.tsx
@@ -5,7 +5,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
-import React from "react";
+import React, { useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
@@ -13,19 +13,24 @@ import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
-
-// Validation schema using Yup
-const schema = yup.object().shape({
- bio: yup.string().required("نوشتن bio الزامی است"),
-});
-
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
function PublicRelations() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ bio: yup.string().required(t("auth.bioRequired")),
+ }),
+ [t]
+ );
+
const formik = useFormik({
initialValues: {
selectedButton: null,
@@ -41,7 +46,7 @@ function PublicRelations() {
});
router.push("/verify/location");
} catch (err: any) {
- toast.error(err?.message || "خطای نامشخصی رخ داد.");
+ toast.error(err?.response?.data?.message || t("auth.unknownError"));
} finally {
setSubmitting(false);
}
@@ -49,55 +54,61 @@ function PublicRelations() {
});
return (
-
-
-
- روابط عمومی
-
+
+
+
+
+
+ {t("auth.publicRelationsTitle")}
+
+
-
-
در مورد خودتان چیزی بگویید
-
{
- if (e.target.value.length <= 500) {
- formik.handleChange(e);
- }
- }}
- onBlur={formik.handleBlur}
- />
+
+
+ {t("auth.bioAboutYou")}
+
+ {
+ if (e.target.value.length <= 500) {
+ formik.handleChange(e);
+ }
+ }}
+ onBlur={formik.handleBlur}
+ />
-
- {formik.values.bio.length}/500
-
+
+ {formik.values.bio.length}/500
+
- {formik.touched.bio && formik.errors.bio && (
-
- {formik.errors.bio}
-
- )}
-
-
+ {formik.touched.bio && formik.errors.bio && (
+
+ {formik.errors.bio}
+
+ )}
+
+
- router.push("/verify/location")}>
-
- ثبت و ادامه
-
-
-
-
+ router.push("/verify/location")}>
+
+ {t("auth.saveAndContinue")}
+
+
+
+
+
);
}
diff --git a/src/app/(auth)/verify/services/page.tsx b/src/app/(auth)/verify/services/page.tsx
index 9613141..6118b80 100644
--- a/src/app/(auth)/verify/services/page.tsx
+++ b/src/app/(auth)/verify/services/page.tsx
@@ -14,8 +14,11 @@ import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import { dataURLtoBlob } from "@/helpers/helpers";
import { useRouter } from "next/navigation";
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
const ServicesPage: React.FC = () => {
+ const { t } = useTranslation("common");
const [services, setServices] = useState
([]);
const [isModalOpen, setModalOpen] = useState(false);
const { request, loading } = useAxios();
@@ -24,12 +27,10 @@ const ServicesPage: React.FC = () => {
typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : ""
);
- // افزودن خدمت جدید
const handleAddService = (newService: Service) => {
setServices((prev) => [...prev, newService]);
};
- // حذف خدمت
const handleDeleteService = (id: string) => {
setServices((prev) => prev.filter((service) => service.id !== id));
};
@@ -38,14 +39,13 @@ const ServicesPage: React.FC = () => {
const handleSubmit = async () => {
if (services.length === 0) {
- toast.error("لطفاً حداقل یک خدمت وارد کنید.");
+ toast.error(t("auth.minOneService"));
return;
}
const formData = new FormData();
formData.append("services", JSON.stringify(services));
- // اضافه کردن تصاویر خدمات
services.forEach(({ id, image }) => {
if (image) {
formData.append(
@@ -60,8 +60,8 @@ const ServicesPage: React.FC = () => {
await request("post", "/verify/services", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
- toast.success("خدمات با موفقیت ثبت شدند!");
- setServices([]); // ریست لیست پس از ثبت
+ toast.success(t("auth.servicesSavedSuccess"));
+ setServices([]);
if (expertise === "مدل") {
router.push("/verify/sizes");
@@ -70,7 +70,7 @@ const ServicesPage: React.FC = () => {
}
} catch (error) {
console.log(error);
- toast.error("خطا در ثبت خدمات!");
+ toast.error(t("auth.servicesSaveError"));
}
};
@@ -83,53 +83,55 @@ const ServicesPage: React.FC = () => {
};
return (
-
-
- خدمات
+
+
+
+ {t("auth.servicesTitle")}
-
+
-
- {services.map((service) => (
-
+ {services.map((service) => (
+
+ ))}
+
+
+ {isModalOpen && (
+ setModalOpen(false)}
+ onAdd={handleAddService}
+ isModalOpen={isModalOpen}
/>
- ))}
-
+ )}
+
- {isModalOpen && (
-
setModalOpen(false)}
- onAdd={handleAddService}
- isModalOpen={isModalOpen}
- />
- )}
-
+
+
+
+ {t("auth.saveAndContinue")}
+
-
-
-
- ثبت و ادامه
-
-
-
setModalOpen(true)}
- className="!text-[#0066FF] !border-[#0066FF] w-32"
- >
- افزودن
-
-
-
-
+
setModalOpen(true)}
+ className="!text-[#0066FF] !border-[#0066FF] w-32"
+ >
+ {t("auth.addService")}
+
+
+
+
+
);
};
diff --git a/src/app/(auth)/verify/sizes/page.tsx b/src/app/(auth)/verify/sizes/page.tsx
index aa55cfa..343633a 100644
--- a/src/app/(auth)/verify/sizes/page.tsx
+++ b/src/app/(auth)/verify/sizes/page.tsx
@@ -5,7 +5,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import * as yup from "yup";
import { useRouter } from "next/navigation";
@@ -13,21 +13,17 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
import Image from "next/image";
-
-// Validation schema using Yup
-const schema = yup.object().shape({
- size: yup.string().required("انتخاب سایز الزامی است"),
- weight: yup.string().required("وارد کردن وزن الزامی است"),
- height: yup.string().required("وارد کردن قد الزامی است"),
-});
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
const sizes = ["34", "36", "38", "40", "42", "44", "46", "48", "50"];
function Sizes() {
+ const { t } = useTranslation("common");
const [height, setHeight] = useState("");
const [weight, setWeight] = useState("");
const [size, setSize] = useState(null);
- const [selectedButton, setSelectedButton] = useState(1); // Default: 1 for "قد"
+ const [selectedButton, setSelectedButton] = useState(1);
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
@@ -37,6 +33,16 @@ function Sizes() {
typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : ""
);
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ size: yup.string().required(t("auth.sizeRequired")),
+ weight: yup.string().required(t("auth.weightRequired")),
+ height: yup.string().required(t("auth.heightRequired")),
+ }),
+ [t]
+ );
+
useEffect(() => {
if (expertise && expertise !== "مدل") {
router.replace("/verify/public-relations");
@@ -65,7 +71,7 @@ function Sizes() {
await request("POST", "/verify/sizes", data);
router.push("/verify/colors");
} catch (error: any) {
- toast.error(error?.message || "خطای نامشخصی رخ داد.");
+ toast.error(error?.response?.data?.message || t("auth.unknownError"));
}
};
@@ -78,121 +84,123 @@ function Sizes() {
};
return (
-
-
- سایز
-
+
+
+
+ {t("auth.sizesTitle")}
+
- مشخصات ظاهری
+
+ {t("auth.appearanceDetails")}
+
- {/* Buttons for categories */}
-
-
handleButtonPress(1)}
+
+ handleButtonPress(1)}
+ >
+
+ {t("auth.height")}
+
+ handleButtonPress(2)}
+ >
+
+ {t("auth.weight")}
+
+ handleButtonPress(3)}
+ >
+
+ {t("auth.size")}
+
+
+
+
+
+
+ router.push("/verify/colors")}>
+
-
- قد
-
-
handleButtonPress(2)}
- >
-
- وزن
-
-
handleButtonPress(3)}
- >
-
- سایز
-
-
-
- {/* Inputs based on selected button */}
-
-
-
- router.push("/verify/colors")}>
-
- ثبت و ادامه
-
-
-
+ {t("auth.saveAndContinue")}
+
+
+
+
);
}
diff --git a/src/app/(projects)/academy/[[...slug]]/page.tsx b/src/app/(projects)/academy/[[...slug]]/page.tsx
index 2ed90a0..8205fdb 100644
--- a/src/app/(projects)/academy/[[...slug]]/page.tsx
+++ b/src/app/(projects)/academy/[[...slug]]/page.tsx
@@ -3,8 +3,9 @@ import { cookies } from "next/headers";
import InfinitePosts from "@/components/academy/InfinitePakage";
import { Metadata } from "next";
import AcademyFilter from "@/components/academy/AcademyFilter";
-import { pageSeo } from "@/config/pageSeo";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import { getSeoPage } from "@/lib/i18n/seo";
+import { generateSeoPageMetadata, getServerLanguage } from "@/lib/i18n/server";
import {
buildAcademyListSeo,
AcademyListFilters,
@@ -50,6 +51,7 @@ function normalizeAcademyFilters(
export async function generateMetadata({
searchParams,
}: IAcademyListProps): Promise {
+ const lang = await getServerLanguage();
const filters = normalizeAcademyFilters(await searchParams);
const hasFilters = Boolean(
filters.category ||
@@ -60,21 +62,24 @@ export async function generateMetadata({
);
if (!hasFilters) {
- return generatePageMetadata({
- title: pageSeo.academy.title,
- description: pageSeo.academy.description,
- path: pageSeo.academy.path,
- keywords: [...pageSeo.academy.keywords],
- });
+ return generateSeoPageMetadata("academy", { lang });
}
- const seo = buildAcademyListSeo(filters);
- return generatePageMetadata({
+ const seo = buildAcademyListSeo(filters, lang);
+ const academyPage = getSeoPage("academy", lang);
+ const meta = generatePageMetadata({
title: seo.title,
+ ogTitle: seo.title,
description: seo.description,
path: seo.path,
- keywords: [...pageSeo.academy.keywords],
+ keywords: academyPage.keywords,
+ lang,
});
+
+ return {
+ ...meta,
+ title: { absolute: seo.title },
+ };
}
export default async function AcademyListPage({
diff --git a/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx b/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx
index e58efaf..eee598a 100644
--- a/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx
+++ b/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx
@@ -30,18 +30,19 @@ import { usePathname } from "next/navigation";
import Cookies from "js-cookie";
import BillboardContactInfoModal from "@/components/billboards/BillboardPage/BillboardContactInfoModal";
import { AcademyCourseDetailSkeleton } from "@/components/academy/AcademySkeletons";
+import { useTranslation } from "react-i18next";
// ==================== دیتای فیک ====================
const MOCK_COURSE = {
_id: "course_123",
- cuorse_name: "در حال بارگذاری...",
+ cuorse_name: "",
price: 0,
- category: "در حال بارگذاری...",
+ category: "",
offer: 0,
- caption: "در حال بارگذاری...",
- course_time: "در حال بارگذاری...",
- teacher_name: "در حال بارگذاری...",
- course_image: "در حال بارگذاری...",
+ caption: "",
+ course_time: "",
+ teacher_name: "",
+ course_image: "",
number_of_course_content: "0",
averageRate: 0,
};
@@ -49,11 +50,11 @@ const MOCK_COURSE = {
const MOCK_VIDEOS = [
{
_id: "video_1",
- file_name: "در حال بارگذاری...",
+ file_name: "",
course_video: "/videos/sample1.mp4",
is_free: false,
type: "video",
- duration: "در حال بارگذاری...",
+ duration: "",
}
];
@@ -97,6 +98,7 @@ export default function CourseDetail({
courseId,
initialIsPurchased = false,
}: CourseDetailProps) {
+ const { t } = useTranslation("common");
const [selectedVideo, setSelectedVideo] = useState(MOCK_VIDEOS[0]);
const [isPurchased, setIsPurchased] = useState(initialIsPurchased);
const [purchasing, setPurchasing] = useState(false);
@@ -147,7 +149,7 @@ export default function CourseDetail({
setSelectedVideo(getAcademyCourses.data.courses[0])
} catch (err) {
console.log("get error:", err);
- toast.error("خطا در دریافت اطلاعات");
+ toast.error(t("academy.course.loadError"));
} finally {
}
};
@@ -176,7 +178,7 @@ export default function CourseDetail({
} catch (err) {
console.log("get error:", err);
- toast.error("خطا در دریافت اطلاعات");
+ toast.error(t("academy.course.loadError"));
}
};
@@ -271,7 +273,7 @@ export default function CourseDetail({
uniqueUserIds.forEach((userId, index) => {
userMap[userId] = usersData[index] || {
_id: userId,
- user_name: "کاربر ناشناس",
+ user_name: t("common.user"),
profile_image: null,
is_verified: "unverified"
};
@@ -303,7 +305,7 @@ export default function CourseDetail({
}
} catch (error) {
console.error("Error fetching comments:", error);
- toast.error("خطا در دریافت نظرات");
+ toast.error(t("academy.commentsError"));
} finally {
setLoading(false);
}
@@ -362,7 +364,7 @@ export default function CourseDetail({
}
} catch (err) {
console.log("get error:", err);
- toast.error("خطا در دریافت اطلاعات");
+ toast.error(t("academy.course.loadError"));
setCourses([]);
} finally {
setCourseLoading(false);
@@ -410,15 +412,15 @@ export default function CourseDetail({
const token = Cookies.get("token");
if (!token) {
- toast.error("لطفاً ابتدا وارد حساب کاربری خود شوید");
+ toast.error(t("academy.course.loginToBuy"));
return;
}
setPurchasing(true);
const loadingToastId = toast.loading(
courses[0]?.is_free
- ? "در حال ثبت پکیج رایگان..."
- : "در حال اتصال به درگاه پرداخت..."
+ ? t("academy.course.registeringFree")
+ : t("academy.course.connectingPayment")
);
try {
@@ -430,7 +432,7 @@ export default function CourseDetail({
if (response?.free) {
setIsPurchased(true);
- toast.success(response?.message || "پکیج رایگان با موفقیت ثبت شد");
+ toast.success(response?.message || t("academy.course.freeRegistered"));
return;
}
@@ -449,12 +451,14 @@ export default function CourseDetail({
}
toast.error(
- response?.message || response?.data?.message || "خطا در اتصال به درگاه پرداخت"
+ response?.message ||
+ response?.data?.message ||
+ t("academy.course.paymentError")
);
} catch (err: unknown) {
toast.dismiss(loadingToastId);
console.error("خطا در خرید:", err);
- toast.error("خطا در شروع پرداخت");
+ toast.error(t("academy.course.paymentStartError"));
} finally {
setPurchasing(false);
}
@@ -474,12 +478,12 @@ export default function CourseDetail({
const token = Cookies.get("token");
if (!token) {
- toast.error("لطفاً ابتدا وارد حساب کاربری خود شوید");
+ toast.error(t("academy.course.loginToBuy"));
return
}
// نمایش لودینگ
- loadingToastId = toast.loading("در حال اتصال به درگاه پرداخت...");
+ loadingToastId = toast.loading(t("academy.course.connectingPayment"));
console.log("ارسال درخواست پرداخت:", {
planDuration: plan.duration,
@@ -523,17 +527,21 @@ export default function CourseDetail({
const newWindow = window.open(paymentUrl, "_blank");
if (!newWindow) {
- toast.error("پاپآپ مسدود شده است. لطفاً اجازه باز کردن پنجره جدید را بدهید.");
+ toast.error(t("academy.course.popupBlocked"));
return;
}
- toast.success("به درگاه پرداخت هدایت شدید");
+ toast.success(t("academy.course.redirectedToPayment"));
} else {
console.error("ساختار پاسخ نامعتبر:", response);
- toast.error(response?.data?.message || response?.message || "خطا در دریافت اطلاعات پرداخت");
+ toast.error(
+ response?.data?.message ||
+ response?.message ||
+ t("academy.course.paymentStartError")
+ );
}
} catch (err: any) {
@@ -545,7 +553,7 @@ export default function CourseDetail({
console.error("خطا در شروع پرداخت:", err);
// استخراج پیام خطا
- let errorMessage = "خطا در شروع پرداخت";
+ let errorMessage = t("academy.course.paymentStartError");
if (err?.response?.data?.message) {
errorMessage = err.response.data.message;
@@ -555,11 +563,10 @@ export default function CourseDetail({
errorMessage = err.message;
}
- // نمایش خطاهای خاص زرینپال
if (errorMessage.includes("amount") || errorMessage.includes("مبلغ")) {
- errorMessage = "مبلغ پرداختی نامعتبر است";
+ errorMessage = t("academy.course.invalidAmount");
} else if (errorMessage.includes("merchant")) {
- errorMessage = "خطا در تنظیمات درگاه پرداخت";
+ errorMessage = t("academy.course.gatewayConfigError");
}
toast.error(errorMessage);
@@ -570,11 +577,11 @@ export default function CourseDetail({
// پخش ویدیو
const handlePlayVideo = (video: (typeof MOCK_VIDEOS)[0]) => {
if (!video.is_free && !isPurchased) {
- toast.error("برای مشاهده این ویدیو باید دوره را خریداری کنید");
+ toast.error(t("academy.course.mustPurchaseVideo"));
return;
}
setSelectedVideo(video);
- toast.success(`در حال پخش: ${video.file_name}`);
+ toast.success(t("academy.course.playing", { name: video.file_name }));
};
// جلوگیری از دانلود ویدیو
@@ -584,7 +591,7 @@ export default function CourseDetail({
| React.KeyboardEvent
) => {
e.preventDefault();
- toast.error("دانلود این ویدیو امکان پذیر نیست");
+ toast.error(t("academy.course.downloadBlocked"));
};
const finalPrice = courses[0]?.is_free
@@ -626,7 +633,7 @@ useEffect(() => {
const paymentStatus = params.get('payment');
if (paymentStatus === 'success') {
- toast.success("پرداخت با موفقیت انجام شد");
+ toast.success(t("academy.course.paymentSuccess"));
setIsPurchased(true);
}
}, []);
@@ -654,17 +661,17 @@ useEffect(() => {
disablePictureInPicture
>
- مرورگر شما از پخش ویدیو پشتیبانی نمیکند
+ {t("academy.course.videoUnsupported")}
{!selectedVideo?.is_free && !isPurchased && (
- این ویدیو رایگان نیست
+ {t("academy.course.videoNotFree")}
- برای مشاهده تمام ویدیوها دوره را تهیه کنید
+ {t("academy.course.purchaseToWatch")}
{
>
{courses[0]?.is_free
- ? "دریافت رایگان پکیج"
- : `خرید دوره با ${Math.round(finalPrice).toLocaleString()} تومان`}
+ ? t("academy.course.getFreePackage")
+ : t("academy.course.buyForPrice", {
+ price: Math.round(finalPrice).toLocaleString(),
+ })}
)}
@@ -685,7 +694,9 @@ useEffect(() => {
{selectedVideo?.duration || ""}
{selectedVideo?.is_free && (
- ✓ ویدیوی رایگان
+
+ ✓ {t("academy.course.freeVideo")}
+
)}
@@ -695,7 +706,7 @@ useEffect(() => {
- درباره دوره
+ {t("academy.course.about")}
{courses[0]?.caption || course.caption}
@@ -706,9 +717,11 @@ useEffect(() => {
- نظرات دانشجویان
+ {t("academy.course.studentComments")}
{totalComments > 0 && (
- ({totalComments} نظر)
+
+ {t("academy.course.commentCount", { count: totalComments })}
+
)}
@@ -716,7 +729,9 @@ useEffect(() => {
-
میانگین امتیاز
+
+ {t("academy.course.averageRating")}
+
@@ -731,7 +746,7 @@ useEffect(() => {
>
{!loading && comments.length === 0 ? (
- هنوز نظری ثبت نشده است. اولین نفری باشید که نظر میدهید!
+ {t("academy.course.noReviewsYet")}
) : (
comments.map((item, index) => (
@@ -747,7 +762,7 @@ useEffect(() => {
{
{/* اطلاعات کاربر و امتیاز */}
- {item?.user_id?.user_name || "کاربر ناشناس"}
+ {item?.user_id?.user_name || t("common.user")}
@@ -782,7 +797,7 @@ useEffect(() => {
{item.createdAt
? new Date(item.createdAt).toLocaleDateString("fa-IR")
- : "تاریخ نامشخص"}
+ : t("academy.course.unknownDate")}
❤️ {item.likes || 0}
@@ -798,7 +813,7 @@ useEffect(() => {
- در حال بارگذاری نظرات...
+ {t("academy.course.loadingComments")}
)}
@@ -835,27 +850,34 @@ useEffect(() => {
{/* قیمت */}
{courses[0]?.is_free ? (
-
رایگان
+
+ {t("academy.course.free")}
+
) : courses[0]?.offerNumber > 0 ? (
{Math.round(
courses[0]?.price * (1 - courses[0]?.offerNumber / 100)
- ).toLocaleString()} تومان
+ ).toLocaleString()}{" "}
+ {t("settings.toman")}
- {Math.round(courses[0]?.price).toLocaleString()} تومان
+ {Math.round(courses[0]?.price).toLocaleString()}{" "}
+ {t("settings.toman")}
- {courses[0]?.offerNumber}% تخفیف
+ {t("academy.course.discount", {
+ percent: courses[0]?.offerNumber,
+ })}
) : (
- {Math.round(courses[0]?.price || course.price).toLocaleString()} تومان
+ {Math.round(courses[0]?.price || course.price).toLocaleString()}{" "}
+ {t("settings.toman")}
)}
@@ -864,7 +886,7 @@ useEffect(() => {
{isPurchased ? (
- دوره خریداری شده است
+ {t("academy.course.purchased")}
) : (
{
{purchasing
? courses[0]?.is_free
- ? "در حال ثبت..."
- : "در حال اتصال به درگاه..."
+ ? t("academy.course.registering")
+ : t("academy.course.connecting")
: courses[0]?.is_free
- ? "دریافت رایگان پکیج"
- : "خرید دوره"}
+ ? t("academy.course.getFreePackage")
+ : t("academy.course.buyCourse")}
)}
@@ -890,7 +912,7 @@ useEffect(() => {
className="w-full mt-3 border border-[#FF107D] text-[#FF107D] py-3 rounded-lg font-bold flex items-center justify-center gap-2 transition-colors hover:bg-[#FF107D10]"
>
- اطلاعات تماس
+ {t("academy.course.contactInfo")}
)}
@@ -898,24 +920,31 @@ useEffect(() => {
- مدرس: {courses[0]?.teacher_name || course.teacher_name}
+
+ {t("academy.course.teacher")}{" "}
+ {courses[0]?.teacher_name || course.teacher_name}
+
- مدت زمان: {courses[0]?.course_time || course.course_time}
+ {t("academy.course.duration")}{" "}
+ {courses[0]?.course_time || course.course_time}
- تعداد ویدیوها: {courses[0]?.number_of_course_content || course.number_of_course_content}
+ {t("academy.course.videoCountLabel")}{" "}
+ {courses[0]?.number_of_course_content ||
+ course.number_of_course_content}
- دسته بندی: {courses[0]?.category || course.category}
+ {t("academy.course.category")}{" "}
+ {courses[0]?.category || course.category}
@@ -928,9 +957,11 @@ useEffect(() => {
- سرفصلهای دوره
+ {t("academy.course.syllabus")}
- ({courses[0]?.number_of_course_content} ویدیو)
+ {t("academy.course.videoCountShort", {
+ count: courses[0]?.number_of_course_content,
+ })}
@@ -989,7 +1020,7 @@ useEffect(() => {
{video.is_free && !isPurchased && (
- رایگان
+ {t("academy.course.free")}
)}
{isLocked &&
}
diff --git a/src/app/(projects)/academy/[id]/[title]/page.tsx b/src/app/(projects)/academy/[id]/[title]/page.tsx
index c32ee3f..5de05ad 100644
--- a/src/app/(projects)/academy/[id]/[title]/page.tsx
+++ b/src/app/(projects)/academy/[id]/[title]/page.tsx
@@ -3,6 +3,8 @@ import CourseDetail from "./CourseClient";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { buildCourseDetailSeo } from "@/lib/buildCourseDetailSeo";
+import { getServerLanguage } from "@/lib/i18n/server";
+import { translateCommon } from "@/lib/i18n/translate";
type Props = {
params: Promise<{ id: string; title: string }>;
@@ -24,23 +26,28 @@ export async function generateMetadata({ params }: Props): Promise
{
response?.[0];
if (course) {
- const seo = buildCourseDetailSeo(course, id);
+ const lang = await getServerLanguage();
+ const seo = buildCourseDetailSeo(course, id, lang);
return generatePageMetadata({
title: seo.title,
description: seo.description,
path: seo.path,
type: "article",
+ lang,
});
}
} catch (error) {
console.error("خطا در دریافت اطلاعات سئو:", error);
}
+ const lang = await getServerLanguage();
+
return generatePageMetadata({
- title: "جزئیات پکیج | مدستاگرام",
- description: "مشاهده جزئیات پکیج آموزشی در مدستاگرام.",
+ title: translateCommon(lang, "academy.course.metaTitle"),
+ description: translateCommon(lang, "academy.course.metaDescription"),
path: `/academy/${id}/${encodeURIComponent(slugTitle || "course")}`,
+ lang,
});
}
diff --git a/src/app/(projects)/academy/layout.tsx b/src/app/(projects)/academy/layout.tsx
index 4e088a1..c985f39 100644
--- a/src/app/(projects)/academy/layout.tsx
+++ b/src/app/(projects)/academy/layout.tsx
@@ -1,11 +1,12 @@
import Header from "@/components/main/Header";
import TabNavigation from "@/components/TabNavigation";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
- {children}
+ {children}
);
diff --git a/src/app/(projects)/academy/loading.tsx b/src/app/(projects)/academy/loading.tsx
new file mode 100644
index 0000000..692af7b
--- /dev/null
+++ b/src/app/(projects)/academy/loading.tsx
@@ -0,0 +1,15 @@
+import IOSSpinner from "@/components/ui/IOSSpinner";
+import { getServerLanguage } from "@/lib/i18n/server";
+import { translateCommon } from "@/lib/i18n/translate";
+
+export default async function AcademyLoading() {
+ const lang = await getServerLanguage();
+ const label = translateCommon(lang, "academy.loading");
+
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/src/app/(projects)/academy/payment/failed/page.tsx b/src/app/(projects)/academy/payment/failed/page.tsx
index 040da9f..fab8883 100644
--- a/src/app/(projects)/academy/payment/failed/page.tsx
+++ b/src/app/(projects)/academy/payment/failed/page.tsx
@@ -9,8 +9,10 @@ import RoundedButton from "@/components/elements/RoundedButton";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import MainProjectCard from "@/components/projects/MainProjectCard";
+import { useTranslation } from "react-i18next";
function FailedProject() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const searchParams = useSearchParams();
@@ -19,6 +21,17 @@ function FailedProject() {
const [project, setProject] = useState();
const [typeList, setTypeList] = useState(null);
const [price, setPrice] = useState("");
+
+ const getDisplayTypeLabel = (projectType?: string) => {
+ if (projectType === "normal") {
+ return t("projects.newProject.displaySimple");
+ }
+ if (projectType === "force") {
+ return t("projects.newProject.displayUrgent");
+ }
+ return t("projects.newProject.displayHighlight");
+ };
+
useEffect(() => {
const fetchAd = async () => {
const response = await request<{ project: Project }>(
@@ -47,7 +60,7 @@ function FailedProject() {
(item) => item.name === project.project_type
);
if (foundType) {
- setPrice(String(foundType.price)); // فرض بر اینکه price عددی است و نیاز به تبدیل به رشته دارد
+ setPrice(String(foundType.price));
}
}
}, [project, typeList]);
@@ -58,58 +71,58 @@ function FailedProject() {
"POST",
"/projects/initiate-payment-web",
{
- item_name: project?.project_type, // Pass advertisingId to initiate payment
+ item_name: project?.project_type,
projectId: projectId,
}
);
if (response.type === "free") {
router.push("/settings/workroom");
} else {
- const authority = response.authority; // Get the payment authority
- const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`; // Construct payment URL
+ const authority = response.authority;
+ const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
}
} catch (error: unknown) {
console.log(error);
}
};
+
return (
- پرداخت ناموفق
+ {t("academy.payment.failed")}
- پرداخت شما با خطا مواجه شد.
- برای تایید درخواست پرداخت خود را کامل کنید
+ {t("academy.payment.failedHint")}
+ {t("academy.payment.completePayment")}
{project &&
}
- {project?.project_type === "normal"
- ? "نمایش ساده"
- : project?.project_type === "force"
- ? "نمایش با برچسب فوری"
- : "نمایش با رنگ پس زمینه متفاوت"}
- : {Number(price).toLocaleString()} تومان
+ {getDisplayTypeLabel(project?.project_type)}:{" "}
+ {Number(price).toLocaleString()} {t("settings.toman")}
- مبلغ قابل پرداخت: {Number(price).toLocaleString()} تومان
+ {t("academy.payment.payableAmount", {
+ price: Number(price).toLocaleString(),
+ })}
{
payHandler();
}}
- variant="primary" className="w-32 h-9"
+ variant="primary"
+ className="w-32 h-9"
>
- پرداخت مجدد
+ {t("academy.payment.retry")}
diff --git a/src/app/(projects)/academy/payment/success/page.tsx b/src/app/(projects)/academy/payment/success/page.tsx
index af97e4b..94a3877 100644
--- a/src/app/(projects)/academy/payment/success/page.tsx
+++ b/src/app/(projects)/academy/payment/success/page.tsx
@@ -10,8 +10,10 @@ import { useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import MainProjectCard from "@/components/projects/MainProjectCard";
+import { useTranslation } from "react-i18next";
function SuccessProject() {
+ const { t } = useTranslation("common");
const { request } = useAxios();
const searchParams = useSearchParams();
const projectId = searchParams.get("projectId");
@@ -19,6 +21,17 @@ function SuccessProject() {
const [project, setProject] = useState();
const [typeList, setTypeList] = useState(null);
const [price, setPrice] = useState("");
+
+ const getDisplayTypeLabel = (projectType?: string) => {
+ if (projectType === "normal") {
+ return t("projects.newProject.displaySimple");
+ }
+ if (projectType === "force") {
+ return t("projects.newProject.displayUrgent");
+ }
+ return t("projects.newProject.displayHighlight");
+ };
+
useEffect(() => {
const fetchAd = async () => {
const response = await request<{ project: Project }>(
@@ -47,20 +60,21 @@ function SuccessProject() {
(item) => item.name === project.project_type
);
if (foundType) {
- setPrice(String(foundType.price)); // فرض بر اینکه price عددی است و نیاز به تبدیل به رشته دارد
+ setPrice(String(foundType.price));
}
}
}, [project, typeList]);
+
return (
- پرداخت موفق
+ {t("academy.payment.success")}
@@ -69,19 +83,13 @@ function SuccessProject() {
{project &&
}
- {project?.project_type === "normal"
- ? "نمایش ساده"
- : project?.project_type === "force"
- ? "نمایش با برچسب فوری"
- : "نمایش با رنگ پس زمینه متفاوت"}
- : {Number(price).toLocaleString()} تومان
+ {getDisplayTypeLabel(project?.project_type)}:{" "}
+ {Number(price).toLocaleString()} {t("settings.toman")}
-
- پروژه شما پس از بررسی توسط کارشناسان ما منتشر خواهد شد
-
+
{t("academy.payment.reviewNotice")}
- اتاق کار
+ {t("academy.payment.workroom")}
diff --git a/src/app/(projects)/academy/profile/[academyId]/AcademyProfileClient.tsx b/src/app/(projects)/academy/profile/[academyId]/AcademyProfileClient.tsx
index cdba142..1fbcc8d 100644
--- a/src/app/(projects)/academy/profile/[academyId]/AcademyProfileClient.tsx
+++ b/src/app/(projects)/academy/profile/[academyId]/AcademyProfileClient.tsx
@@ -8,16 +8,18 @@ import {
AcademyProfileHeadSkeleton,
} from "@/components/academy/AcademySkeletons";
import Container from "@/components/elements/Container";
-import RoundedButton from "@/components/elements/RoundedButton";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { useUserById } from "@/hooks/getUserById";
import { Course, IContactInfo } from "@/types/types";
+import { profileActionBtnClass } from "@/lib/ui/buttonStyles";
+import { cn } from "@/lib/utils";
import Image from "next/image";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
interface AcademyStats {
packagesCount: number;
@@ -51,17 +53,18 @@ interface AcademyData {
function openContact(
type: "mobile" | "whatsapp" | "telegram" | "instagram",
- contact?: IContactInfo
+ contact: IContactInfo | undefined,
+ t: (key: string) => string
) {
if (!contact) {
- toast.error("اطلاعات تماس ثبت نشده است.");
+ toast.error(t("academy.profile.contact.none"));
return;
}
if (type === "mobile") {
const phone = contact.mobile || contact.phone;
if (!phone) {
- toast.error("شماره موبایل ثبت نشده است.");
+ toast.error(t("academy.profile.contact.mobileMissing"));
return;
}
window.open(`tel:${phone}`, "_self");
@@ -70,7 +73,7 @@ function openContact(
if (type === "whatsapp") {
if (!contact.whatsappNumber) {
- toast.error("شماره واتساپ ثبت نشده است.");
+ toast.error(t("academy.profile.contact.whatsappMissing"));
return;
}
window.open(
@@ -82,7 +85,7 @@ function openContact(
if (type === "telegram") {
if (!contact.telegramLink) {
- toast.error("آیدی تلگرام ثبت نشده است.");
+ toast.error(t("academy.profile.contact.telegramMissing"));
return;
}
window.open(`https://t.me/${contact.telegramLink.replace("@", "")}`, "_blank");
@@ -90,7 +93,7 @@ function openContact(
}
if (!contact.instagramLink) {
- toast.error("آیدی اینستاگرام ثبت نشده است.");
+ toast.error(t("academy.profile.contact.instagramMissing"));
return;
}
window.open(
@@ -99,7 +102,19 @@ function openContact(
);
}
+function isContactAvailable(
+ type: "mobile" | "whatsapp" | "telegram" | "instagram",
+ contact?: IContactInfo
+): boolean {
+ if (!contact) return false;
+ if (type === "mobile") return Boolean(contact.mobile || contact.phone);
+ if (type === "whatsapp") return Boolean(contact.whatsappNumber);
+ if (type === "telegram") return Boolean(contact.telegramLink);
+ return Boolean(contact.instagramLink);
+}
+
export default function AcademyProfileClient() {
+ const { t } = useTranslation("common");
const params = useParams();
const academyId = params?.academyId as string;
const { request } = useAxios();
@@ -128,7 +143,7 @@ export default function AcademyProfileClient() {
response?.academy || response?.data?.academy || response;
setAcademy(academyData);
} catch {
- toast.error("خطا در دریافت اطلاعات آموزشگاه");
+ toast.error(t("academy.profile.loadError"));
} finally {
setIsLoading(false);
}
@@ -150,7 +165,7 @@ export default function AcademyProfileClient() {
response?.data?.courses || response?.courses || [];
setCourses(list);
} catch {
- toast.error("خطا در دریافت پکیجها");
+ toast.error(t("academy.profile.packagesError"));
setCourses([]);
} finally {
setIsLoadingCourses(false);
@@ -172,12 +187,14 @@ export default function AcademyProfileClient() {
if (!academy) {
return (
- آموزشگاه یافت نشد.
+
+ {t("academy.profile.notFound")}
+
);
}
- const academyName = academy.academy_name || "آموزشگاه";
+ const academyName = academy.academy_name || t("academy.profile.defaultName");
const owner = academy.owner;
const displayUserName = owner?.user_name || ownerFromApi?.user_name;
const displayUserScore =
@@ -190,37 +207,38 @@ export default function AcademyProfileClient() {
return (
-
{
navigator.clipboard.writeText(shareUrl);
- toast.success("لینک آموزشگاه کپی شد");
+ toast.success(t("academy.profile.addressCopied"));
}}
>
- {shareUrl}
-
+
{t("academy.profile.copyAddress")}
+
{stats?.totalVideos ?? 0}
- کل ویدئوها
+ {t("academy.profile.totalVideos")}
{stats?.packagesCount ?? 0}
- تعداد پکیجها
+ {t("academy.profile.packageCount")}
{stats?.soldCount ?? 0}
- تعداد فروخته شده
+ {t("academy.profile.soldCount")}
@@ -239,22 +257,22 @@ export default function AcademyProfileClient() {
-
+
{Number(academy.rate || 0).toFixed(1)}
-
+
{displayUserScore || 0}
@@ -277,50 +295,73 @@ export default function AcademyProfileClient() {
- {academy.bio || "توضیحاتی برای این آموزشگاه ثبت نشده است."}
+ {academy.bio || t("academy.profile.noBio")}
-
+
openContact("mobile", academy.contactInfo)}
- className="text-[12px] md:text-sm h-7 md:h-8 max-sm:text-[8px] rounded-xl border border-neutral-200 bg-white px-2 dark:border-neutral-700 dark:bg-neutral-900 active:opacity-80"
+ onClick={() => openContact("mobile", academy.contactInfo, t)}
+ disabled={!isContactAvailable("mobile", academy.contactInfo)}
+ className={cn(
+ profileActionBtnClass,
+ !isContactAvailable("mobile", academy.contactInfo) &&
+ "opacity-50"
+ )}
>
- موبایل
+ {t("academy.profile.contact.mobile")}
openContact("whatsapp", academy.contactInfo)}
- className="text-[12px] md:text-sm h-7 md:h-8 max-sm:text-[8px] rounded-xl border border-neutral-200 bg-white px-2 dark:border-neutral-700 dark:bg-neutral-900 active:opacity-80"
+ onClick={() => openContact("whatsapp", academy.contactInfo, t)}
+ disabled={!isContactAvailable("whatsapp", academy.contactInfo)}
+ className={cn(
+ profileActionBtnClass,
+ !isContactAvailable("whatsapp", academy.contactInfo) &&
+ "opacity-50"
+ )}
>
- واتساپ
+ {t("academy.profile.contact.whatsapp")}
openContact("telegram", academy.contactInfo)}
- className="text-[12px] md:text-sm h-7 md:h-8 max-sm:text-[8px] rounded-xl border border-neutral-200 bg-white px-2 dark:border-neutral-700 dark:bg-neutral-900 active:opacity-80"
+ onClick={() => openContact("telegram", academy.contactInfo, t)}
+ disabled={!isContactAvailable("telegram", academy.contactInfo)}
+ className={cn(
+ profileActionBtnClass,
+ !isContactAvailable("telegram", academy.contactInfo) &&
+ "opacity-50"
+ )}
>
- تلگرام
+ {t("academy.profile.contact.telegram")}
- openContact("instagram", academy.contactInfo)}
- className="text-[9px] md:text-sm h-7 md:h-8 max-sm:text-[8px]"
+ openContact("instagram", academy.contactInfo, t)}
+ disabled={!isContactAvailable("instagram", academy.contactInfo)}
+ className={cn(
+ profileActionBtnClass,
+ !isContactAvailable("instagram", academy.contactInfo) &&
+ "opacity-50"
+ )}
>
- اینستاگرام
-
+ {t("academy.profile.contact.instagram")}
+
-
پکیجهای آموزشی
+
+ {t("academy.profile.trainingPackages")}
+
{isLoadingCourses ? (
) : courses.length === 0 ? (
- هنوز پکیجی برای این آموزشگاه ثبت نشده است.
+ {t("academy.profile.noPackages")}
) : (
courses.map((course) => (
diff --git a/src/app/(projects)/academy/profile/[academyId]/page.tsx b/src/app/(projects)/academy/profile/[academyId]/page.tsx
index 3460e67..de485a9 100644
--- a/src/app/(projects)/academy/profile/[academyId]/page.tsx
+++ b/src/app/(projects)/academy/profile/[academyId]/page.tsx
@@ -2,6 +2,8 @@ import { Metadata } from "next";
import AcademyProfileClient from "./AcademyProfileClient";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
+import { getServerLanguage } from "@/lib/i18n/server";
+import { translateCommon } from "@/lib/i18n/translate";
type Props = {
params: Promise<{ academyId: string }>;
@@ -21,20 +23,26 @@ export async function generateMetadata({ params }: Props): Promise
{
const academy = data?.academy;
if (academy?.academy_name) {
+ const lang = await getServerLanguage();
return generatePageMetadata({
- title: `${academy.academy_name} | مدستاگرام`,
+ title: academy.academy_name,
description: academy.bio || academy.academy_name,
path: `/academy/profile/${academyId}`,
+ appendSiteName: true,
+ lang,
});
}
} catch {
// fallback below
}
+ const lang = await getServerLanguage();
+
return generatePageMetadata({
- title: "آموزشگاه | مدستاگرام",
- description: "صفحه آموزشگاه در مدستاگرام",
+ title: translateCommon(lang, "academy.course.profileMetaTitle"),
+ description: translateCommon(lang, "academy.course.profileMetaDescription"),
path: `/academy/profile/${academyId}`,
+ lang,
});
}
diff --git a/src/app/(projects)/new-project/layout.tsx b/src/app/(projects)/new-project/layout.tsx
index 6fe060f..a255eea 100644
--- a/src/app/(projects)/new-project/layout.tsx
+++ b/src/app/(projects)/new-project/layout.tsx
@@ -1,19 +1,20 @@
-import Header from "@/components/main/Header";
-import TabNavigation from "@/components/TabNavigation";
-import type { Metadata } from "next";
-
-export const metadata: Metadata = {
- title: "ثبت پروژه جدید | مدستاگرام",
- description: "ثبت درخواست همکاری و پروژه جدید در مدستاگرام",
- robots: { index: false, follow: false },
-};
-
-export default function Layout({ children }: { children: React.ReactNode }) {
- return (
-
- );
-}
+import Header from "@/components/main/Header";
+import TabNavigation from "@/components/TabNavigation";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
+
+export async function generateMetadata() {
+ return generateSeoPageMetadata("newProject", {
+ index: false,
+ });
+}
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+ );
+}
diff --git a/src/app/(projects)/projects/[id]/[title]/ProjectDetailClient.tsx b/src/app/(projects)/projects/[id]/[title]/ProjectDetailClient.tsx
index 9217ede..3f278fb 100644
--- a/src/app/(projects)/projects/[id]/[title]/ProjectDetailClient.tsx
+++ b/src/app/(projects)/projects/[id]/[title]/ProjectDetailClient.tsx
@@ -3,14 +3,18 @@
import Container from "@/components/elements/Container";
import MainProjectCard from "@/components/projects/MainProjectCard";
import ProjectRequestForm from "@/components/projects/ProjectPage/ProjectRequestForm";
+import ProjectRequests from "@/components/projects/ProjectPage/ProjectRequests";
import PageLoader from "@/components/ui/PageLoader";
import useAxios from "@/hooks/useAxios";
-import { Project } from "@/types/types";
+import { Project, IProjectRequest } from "@/types/types";
import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
export default function ProjectDetailClient({ id }: { id: string }) {
+ const { t } = useTranslation("common");
const { request } = useAxios();
const [project, setProject] = useState(null);
+ const [projectRequests, setProjectRequests] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
@@ -21,13 +25,19 @@ export default function ProjectDetailClient({ id }: { id: string }) {
setLoading(true);
setError(false);
try {
- const response = await request<{ project: Project }>(
+ const response = await request<{
+ project: Project;
+ projectRequests?: IProjectRequest[];
+ }>(
"GET",
`/projects/get/web/${id}`,
null,
{ noToast: true }
);
- if (!cancelled) setProject(response?.project ?? null);
+ if (!cancelled) {
+ setProject(response?.project ?? null);
+ setProjectRequests(response?.projectRequests ?? []);
+ }
} catch {
if (!cancelled) setError(true);
} finally {
@@ -46,21 +56,31 @@ export default function ProjectDetailClient({ id }: { id: string }) {
if (error || !project) {
return (
- پروژه یافت نشد.
+
+ {t("projects.notFound")}
+
);
}
return (
-
-
+
+ {projectRequests.length > 0 ? (
+
+
+ {t("projects.requestUsersTitle")}
+
+
+
+ ) : null}
+
);
}
diff --git a/src/app/(projects)/projects/[id]/[title]/page.tsx b/src/app/(projects)/projects/[id]/[title]/page.tsx
index 0642154..858a013 100644
--- a/src/app/(projects)/projects/[id]/[title]/page.tsx
+++ b/src/app/(projects)/projects/[id]/[title]/page.tsx
@@ -5,7 +5,11 @@ import ProjectDetailClient from "./ProjectDetailClient";
import PageLoader from "@/components/ui/PageLoader";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { fetchProject } from "@/api/fetchProject";
-import { buildProjectDetailSeo } from "@/lib/buildProjectDetailSeo";
+import {
+ buildProjectDetailFallbackSeo,
+ buildProjectDetailSeo,
+} from "@/lib/buildProjectDetailSeo";
+import { getServerLanguage } from "@/lib/i18n/server";
type ProjectDetailPageProps = {
params: Promise<{ id: string; title: string }>;
@@ -16,23 +20,30 @@ export async function generateMetadata({
}: ProjectDetailPageProps): Promise {
const { id, title } = await params;
const token = (await cookies()).get("token")?.value || "";
+ const lang = await getServerLanguage();
const project = await fetchProject(id, token);
if (project) {
- const seo = buildProjectDetailSeo(project);
+ const seo = buildProjectDetailSeo(project, lang);
return generatePageMetadata({
title: seo.title,
description: seo.description,
path: seo.path,
type: "article",
+ lang,
});
}
- const decodedTitle = decodeURIComponent(title || "پروژه");
+ const seo = buildProjectDetailFallbackSeo(
+ decodeURIComponent(title || ""),
+ id,
+ lang
+ );
return generatePageMetadata({
- title: `${decodedTitle} - مدستاگرام`,
- description: `جزئیات پروژه «${decodedTitle}» در مدستاگرام.`,
- path: `/projects/${id}/${encodeURIComponent(decodedTitle)}`,
+ title: seo.title,
+ description: seo.description,
+ path: seo.path,
+ lang,
});
}
diff --git a/src/app/(projects)/projects/layout.tsx b/src/app/(projects)/projects/layout.tsx
index 23bdee3..6d89158 100644
--- a/src/app/(projects)/projects/layout.tsx
+++ b/src/app/(projects)/projects/layout.tsx
@@ -1,5 +1,6 @@
import Header from "@/components/main/Header";
import TabNavigation from "@/components/TabNavigation";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
export default function ProjectsLayout({
children,
@@ -9,7 +10,7 @@ export default function ProjectsLayout({
return (
- {children}
+ {children}
);
diff --git a/src/app/(projects)/projects/page.tsx b/src/app/(projects)/projects/page.tsx
index eb3ba90..d72cb2c 100644
--- a/src/app/(projects)/projects/page.tsx
+++ b/src/app/(projects)/projects/page.tsx
@@ -4,8 +4,9 @@ import ProjectsFilter from "@/components/projects/ProjectsFilter";
import { fetchProjects } from "@/api/fetchProjects";
import { cookies } from "next/headers";
import { Metadata } from "next";
-import { pageSeo } from "@/config/pageSeo";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import { getSeoPage } from "@/lib/i18n/seo";
+import { generateSeoPageMetadata, getServerLanguage } from "@/lib/i18n/server";
import {
buildProjectsListSeo,
ProjectListFilters,
@@ -36,26 +37,30 @@ function normalizeProjectFilters(
export async function generateMetadata({
searchParams,
}: ProjectsPageProps): Promise {
+ const lang = await getServerLanguage();
const filters = normalizeProjectFilters(await searchParams);
const hasFilters = Object.values(filters).some(Boolean);
if (!hasFilters) {
- return generatePageMetadata({
- title: pageSeo.projects.title,
- description: pageSeo.projects.description,
- path: pageSeo.projects.path,
- keywords: [...pageSeo.projects.keywords],
- });
+ return generateSeoPageMetadata("projects", { lang });
}
- const seo = buildProjectsListSeo(filters);
+ const seo = buildProjectsListSeo(filters, lang);
+ const projectsPage = getSeoPage("projects", lang);
- return generatePageMetadata({
+ const meta = generatePageMetadata({
title: seo.title,
+ ogTitle: seo.title,
description: seo.description,
path: seo.path,
- keywords: [...pageSeo.projects.keywords],
+ keywords: projectsPage.keywords,
+ lang,
});
+
+ return {
+ ...meta,
+ title: { absolute: seo.title },
+ };
}
export default async function ProjectsPage({ searchParams }: ProjectsPageProps) {
diff --git a/src/app/[[...slug]]/page.tsx b/src/app/[[...slug]]/page.tsx
index b62b47f..2edbad7 100644
--- a/src/app/[[...slug]]/page.tsx
+++ b/src/app/[[...slug]]/page.tsx
@@ -5,15 +5,16 @@ import InfinitePosts from "@/components/models/InfinitePosts";
import StoriesBar from "@/components/stories/StoriesBar";
import Header from "@/components/main/Header";
import TabNavigation from "@/components/TabNavigation";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import { Metadata } from "next";
-import { pageSeo } from "@/config/pageSeo";
+import { getSeoPage } from "@/lib/i18n/seo";
+import { getServerLanguage } from "@/lib/i18n/server";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
-
-// درونریزی فایلهای دیتا برای استخراج نام شهر و استان
-import provinces from "@/data/provinces.json";
-import cities from "@/data/cities.json";
-import { normalizeUserLevel, parseUserLevelFromSlug } from "@/lib/userLevel";
-import { parseExpertiseFromSlug } from "@/lib/expertiseColors";
+import {
+ buildHomeListSeo,
+ resolveHomeListFilters,
+} from "@/lib/buildHomeListSeo";
+import { normalizeUserLevel } from "@/lib/userLevel";
interface IModelsProps {
params: Promise<{ slug?: string[] }>;
@@ -35,82 +36,31 @@ interface IModelsProps {
}>;
}
-// تابع هوشمند تولید متادیتا
export async function generateMetadata({ params, searchParams }: IModelsProps): Promise {
+ const lang = await getServerLanguage();
const [urlParams, filters] = await Promise.all([params, searchParams]);
-
- const isDefaultHome =
- (!urlParams.slug || urlParams.slug.length === 0) &&
- !filters.expertise &&
- !filters.province &&
- !filters.city &&
- !filters.userLevel &&
- !filters.rateFilter &&
- !filters.hashtag;
-
- if (isDefaultHome) {
- return generatePageMetadata({
- title: pageSeo.home.title,
- description: pageSeo.home.description,
- path: pageSeo.home.path,
- keywords: [...pageSeo.home.keywords],
- });
- }
-
- let locationLabel = "";
- let levelLabel = "";
-
- try {
- if (urlParams.slug && urlParams.slug.length > 0) {
- const decodedText = decodeURIComponent(urlParams.slug[0]).replace(/-/g, ' ');
-
- // اولویت با متن فارسی ساخته شده در URL
- if (decodedText.startsWith("استخدام")) {
- const title = `${decodedText} | مدستاگرام`;
- const description = `لیست برترین متخصصین مد و زیبایی: ${decodedText}. پیدا کردن مدل، عکاس و آرایشگر حرفهای در مدستاگرام.`;
- return generatePageMetadata({
- title,
- description,
- path: `/${urlParams.slug.join("/")}`,
- });
- }
-
- const pSlug = decodeURIComponent(urlParams.slug[0]);
- const province = provinces.find((p) => p.slug === pSlug);
- if (province) {
- locationLabel = ` در استان ${province.name}`;
- if (urlParams.slug.length > 1) {
- const cSlug = decodeURIComponent(urlParams.slug[1]);
- const city = cities.find((c) => c.slug === cSlug);
- if (city) locationLabel = ` در ${city.name}`;
- }
- }
- }
-
- if (!locationLabel) {
- if (filters.city) {
- const city = cities.find(c => c.id.toString() === filters.city);
- if (city) locationLabel = ` در شهر ${city.name}`;
- } else if (filters.province) {
- const province = provinces.find(p => p.id.toString() === filters.province);
- if (province) locationLabel = ` در استان ${province.name}`;
- }
- }
- if (filters.userLevel) levelLabel = ` سطح ${filters.userLevel}`;
- } catch (error) {
- console.error("Error generating metadata", error);
- }
-
- const expertiseLabel = filters.expertise || "مدل، عکاس و آرایشگر";
- const title = `استخدام ${expertiseLabel}${levelLabel}${locationLabel} | مدستاگرام`;
- const description = `پلتفرم تخصصی استخدام ${expertiseLabel}${locationLabel}. بهترین متخصصین حوزه زیبایی و مد را در مدستاگرام پیدا کنید.`;
-
- const path =
+ const slugPath =
urlParams.slug && urlParams.slug.length > 0
? `/${urlParams.slug.join("/")}`
- : pageSeo.home.path;
+ : undefined;
- return generatePageMetadata({ title, description, path });
+ const seoFilters = resolveHomeListFilters(urlParams.slug || [], filters);
+ const seo = buildHomeListSeo(seoFilters, slugPath, lang);
+ const homePage = getSeoPage("home", lang);
+
+ const meta = generatePageMetadata({
+ title: seo.title,
+ ogTitle: seo.title,
+ description: seo.description,
+ path: seo.path,
+ keywords: homePage.keywords,
+ lang,
+ });
+
+ return {
+ ...meta,
+ title: { absolute: seo.title },
+ };
}
export default async function Models({ params, searchParams }: IModelsProps) {
@@ -121,74 +71,28 @@ export default async function Models({ params, searchParams }: IModelsProps) {
]);
const token = cookieStore.get("token")?.value || "";
-
- // ۱. مقادیر پایه (اولویت با کوئری پارامتر برای دقت در فیلتر)
- let provinceId = filters.province || "";
- let cityId = filters.city || "";
- let userLevel = normalizeUserLevel(filters.userLevel || "");
- let expertise = filters.expertise || "";
+ const seoFilters = resolveHomeListFilters(urlParams.slug || [], filters);
+ const seo = buildHomeListSeo(
+ seoFilters,
+ urlParams.slug?.length ? `/${urlParams.slug.join("/")}` : undefined
+ );
- // ۲. استخراج و همگامسازی اطلاعات از URL فارسی (Slug)
- if (urlParams.slug && urlParams.slug.length > 0) {
- const fullText = decodeURIComponent(urlParams.slug[0]).replace(/-/g, ' ');
-
- // استخراج تخصص از slug (مدل، عکاس، پوشاک و هر تخصص جدید)
- const parsedExpertise = parseExpertiseFromSlug(fullText);
- if (parsedExpertise) expertise = parsedExpertise;
- else if (fullText.includes("متخصصین")) expertise = "";
-
- const parsedLevel = parseUserLevelFromSlug(fullText);
- if (parsedLevel) userLevel = parsedLevel;
-
- // استخراج مکان (IDها)
- if (fullText.includes("در شهر")) {
- const cityName = fullText.split("در شهر")[1]?.trim();
- const foundCity = cities.find(c => cityName.includes(c.name));
- if (foundCity) cityId = foundCity.id.toString();
- } else if (fullText.includes("در استان")) {
- const provinceName = fullText.split("در استان")[1]?.trim();
- const foundProvince = provinces.find(p => provinceName.includes(p.name));
- if (foundProvince) provinceId = foundProvince.id.toString();
- }
-
- // هندل کردن اسلاگهای انگلیسی (مثلاً برای سئو قدیمی یا دستی)
- if (!provinceId && !cityId) {
- const p = provinces.find(x => x.slug === urlParams.slug![0]);
- if (p) {
- provinceId = p.id.toString();
- if (urlParams.slug![1]) {
- const c = cities.find(x => x.slug === urlParams.slug![1]);
- if (c) cityId = c.id.toString();
- }
- }
- }
- }
-
- // آمادهسازی لیبلها برای نمایش در صفحه (H1)
- const displayExpertise = expertise || filters.expertise || "متخصصین مد و زیبایی";
- const levelText = userLevel ? ` سطح ${userLevel}` : "";
+ let provinceId = seoFilters.province || "";
+ let cityId = seoFilters.city || "";
+ let userLevel = normalizeUserLevel(seoFilters.userLevel || "");
+ let expertise = seoFilters.expertise || "";
return (
<>
-
- {/* H1 مخفی برای بهبود سئو بر اساس آدرس صفحه */}
-
- {(!urlParams.slug || urlParams.slug.length === 0) &&
- !filters.expertise &&
- !filters.province &&
- !filters.city &&
- !filters.userLevel
- ? pageSeo.home.title.replace(" | مدستاگرام", "")
- : `استخدام ${displayExpertise}${levelText} در مدستاگرام`}
-
+
+
+ {seo.h1}
- {/* کامپوننت فیلتر با مقدار تخصص فعلی */}
-
+
- {/* نمایش پستها با تمام فیلترهای استخراج شده */}
+
>
);
-}
\ No newline at end of file
+}
diff --git a/src/app/about-us/layout.tsx b/src/app/about-us/layout.tsx
index b4b9651..7224921 100644
--- a/src/app/about-us/layout.tsx
+++ b/src/app/about-us/layout.tsx
@@ -1,15 +1,11 @@
import Footer from "@/components/main/Footer";
import Header from "@/components/main/Header";
import type { Metadata } from "next";
-import { pageSeo } from "@/config/pageSeo";
-import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
-export const metadata: Metadata = generatePageMetadata({
- title: pageSeo.about.title,
- description: pageSeo.about.description,
- path: pageSeo.about.path,
- keywords: [...pageSeo.about.keywords],
-});
+export async function generateMetadata(): Promise {
+ return generateSeoPageMetadata("about");
+}
export default function Layout({ children }: { children: React.ReactNode }) {
return (
diff --git a/src/app/about-us/page.tsx b/src/app/about-us/page.tsx
index 2624660..6a19d6a 100644
--- a/src/app/about-us/page.tsx
+++ b/src/app/about-us/page.tsx
@@ -1,36 +1,33 @@
+"use client";
+
import Container from "@/components/elements/Container";
import RoundedDiv from "@/components/elements/RoundedDiv";
import Link from "next/link";
-import React from "react";
+import { useTranslation } from "react-i18next";
function AboutUs() {
+ const { t } = useTranslation("common");
+
return (
-
-
- راه های ارتباطی با مجموعه مدستاگرام
-
-
- ایمیل مجموعه :
- modstagram.com@gmail.com
-
-
- شماره تماس :
- 09128893712
-
+
{t("aboutPage.title")}
+
+ {t("aboutPage.emailLabel")}
+ modstagram.com@gmail.com
+
+
+ {t("aboutPage.phoneLabel")}
+ 09128893712
+
-
-
-
- ارسال تیکت
-
-
+
+
+ {t("aboutPage.sendTicket")}
+
+
diff --git a/src/app/api/auth/config/route.ts b/src/app/api/auth/config/route.ts
new file mode 100644
index 0000000..b671f67
--- /dev/null
+++ b/src/app/api/auth/config/route.ts
@@ -0,0 +1,10 @@
+import { getGoogleClientId } from "@/lib/auth/googleClientId";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export async function GET() {
+ return Response.json({
+ googleClientId: getGoogleClientId(),
+ });
+}
diff --git a/src/app/billboards/[[...slug]]/page.tsx b/src/app/billboards/[[...slug]]/page.tsx
index 8bd4408..8b63f28 100644
--- a/src/app/billboards/[[...slug]]/page.tsx
+++ b/src/app/billboards/[[...slug]]/page.tsx
@@ -1,15 +1,15 @@
import Container from "@/components/elements/Container";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import { cookies } from "next/headers";
-import InfiniteBillboard from "@/components/billboards/InfiniteBillboard";
import BillboardsFilter from "@/components/billboards/BillboardPage/BillboardsFilter";
-import { fetchBillboards } from "@/api/fetchBillboards";
+import BillboardsListSection from "@/components/billboards/BillboardsListSection";
import { Metadata } from "next";
-import { pageSeo } from "@/config/pageSeo";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
-
-// درونریزی دیتاها برای تبدیل متن به ID
-import provinces from "@/data/provinces.json";
-import cities from "@/data/cities.json";
+import { getSeoPage } from "@/lib/i18n/seo";
+import { getServerLanguage } from "@/lib/i18n/server";
+import { Suspense } from "react";
+import { AcademyListSkeleton } from "@/components/academy/AcademySkeletons";
+import { buildBillboardsListSeo } from "@/lib/buildBillboardsListSeo";
interface IBillboardsProps {
params: Promise<{ slug?: string[] }>;
@@ -22,93 +22,27 @@ interface IBillboardsProps {
}>;
}
-// تابع استخراج داده که هم در Metadata و هم در Page استفاده میشود
-const getSEOData = (slugSegments: string[] = [], filters: any) => {
- let provinceId = filters.province || "";
- let cityId = filters.city || "";
- let category = filters.category || "";
- let locationLabel = "";
-
- if (slugSegments && slugSegments.length > 0) {
- const fullText = decodeURIComponent(slugSegments[0]).replace(/-/g, ' ');
-
- if (fullText.startsWith("تبلیغات")) {
- // استخراج دستهبندی
- const afterAds = fullText.split("تبلیغات")[1]?.trim();
- if (afterAds) {
- category = afterAds.split("در")[0]?.trim();
- if (category.includes("خدمات مد و زیبایی")) category = "";
- }
-
- // استخراج مکان و تبدیل به ID برای فیلتر API
- if (fullText.includes("در شهر")) {
- const cityName = fullText.split("در شهر")[1]?.trim();
- const foundCity = cities.find(c => cityName.includes(c.name));
- if (foundCity) {
- cityId = foundCity.id.toString();
- locationLabel = ` در شهر ${foundCity.name}`;
- }
- } else if (fullText.includes("در استان")) {
- const provinceName = fullText.split("در استان")[1]?.trim();
- const foundProvince = provinces.find(p => provinceName.includes(p.name));
- if (foundProvince) {
- provinceId = foundProvince.id.toString();
- locationLabel = ` در استان ${foundProvince.name}`;
- }
- }
- }
- }
-
- // اگر لیبل از URL ساخته نشد، از کوئری پارامتر استفاده کن
- if (!locationLabel) {
- if (filters.city) {
- const city = cities.find(c => c.id.toString() === filters.city);
- if (city) locationLabel = ` در شهر ${city.name}`;
- } else if (filters.province) {
- const province = provinces.find(p => p.id.toString() === filters.province);
- if (province) locationLabel = ` در استان ${province.name}`;
- }
- }
-
- const categoryLabel = category || filters.category || "خدمات حوزه زیبایی و مد";
- return { provinceId, cityId, category, categoryLabel, locationLabel };
-};
-
-// --- متادیتای سئو ---
export async function generateMetadata({ params, searchParams }: IBillboardsProps): Promise {
+ const lang = await getServerLanguage();
const [urlParams, filters] = await Promise.all([params, searchParams]);
+ const seo = buildBillboardsListSeo(urlParams.slug || [], filters, lang);
+ const billboardsPage = getSeoPage("billboards", lang);
- const isDefaultBillboards =
- (!urlParams.slug || urlParams.slug.length === 0) &&
- !filters.province &&
- !filters.city &&
- !filters.category &&
- !filters.search &&
- !filters.sort;
+ const meta = generatePageMetadata({
+ title: seo.title,
+ ogTitle: seo.title,
+ description: seo.description,
+ path: seo.path,
+ keywords: billboardsPage.keywords,
+ lang,
+ });
- if (isDefaultBillboards) {
- return generatePageMetadata({
- title: pageSeo.billboards.title,
- description: pageSeo.billboards.description,
- path: pageSeo.billboards.path,
- keywords: [...pageSeo.billboards.keywords],
- });
- }
-
- const { locationLabel, categoryLabel } = getSEOData(urlParams.slug, filters);
-
- const title = `تبلیغات ${categoryLabel}${locationLabel} | بیلبورد مدستاگرام`;
- const description = `بیلبورد و تبلیغات ${categoryLabel}${locationLabel} در مدستاگرام. فضای ویژه تبلیغات کسبوکارهای حوزه زیبایی برای دیدهشدن بیشتر و جذب مشتری.`;
-
- const path =
- urlParams.slug && urlParams.slug.length > 0
- ? `/billboards/${urlParams.slug.join("/")}`
- : pageSeo.billboards.path;
-
- return generatePageMetadata({ title, description, path });
+ return {
+ ...meta,
+ title: { absolute: seo.title },
+ };
}
-// --- اصلاح فیلتر و محتوا ---
export default async function Billboards({ params, searchParams }: IBillboardsProps) {
const [urlParams, filters, cookieStore] = await Promise.all([
params,
@@ -117,41 +51,26 @@ export default async function Billboards({ params, searchParams }: IBillboardsPr
]);
const token = cookieStore.get("token")?.value || "";
+ const seo = buildBillboardsListSeo(urlParams.slug || [], filters);
- // ۱. استخراج دادههای واقعی از اسلاگ فارسی
- const { provinceId, cityId, category, categoryLabel, locationLabel } = getSEOData(urlParams.slug, filters);
-
- // ۲. ترکیب فیلترها (بسیار مهم: مقادیر استخراج شده از URL جایگزین فیلترهای خالی میشوند)
const combinedFilters = {
...filters,
- province: provinceId || filters.province,
- city: cityId || filters.city,
- category: category || filters.category
+ province: seo.provinceId || filters.province,
+ city: seo.cityId || filters.city,
+ category: seo.category || filters.category
};
- // ۳. ارسال فیلترهای صحیح به API
- const initialData = await fetchBillboards(1, 10, combinedFilters, token);
-
return (
-
-
- {(!urlParams.slug || urlParams.slug.length === 0) &&
- !filters.province &&
- !filters.city &&
- !filters.category &&
- !filters.search
- ? pageSeo.billboards.title.replace(" | مدستاگرام", "")
- : `تبلیغات ${categoryLabel}${locationLabel} در مدستاگرام`}
-
+
+
+ {seo.h1}
-
-
- {/* ۴. پاس دادن فیلترهای ترکیبی به کامپوننت اینفینیت */}
-
-
+
+
+ }>
+
+
+
+
);
-}
\ No newline at end of file
+}
diff --git a/src/app/billboards/[id]/[title]/page.tsx b/src/app/billboards/[id]/[title]/page.tsx
index d537957..c8fad2c 100644
--- a/src/app/billboards/[id]/[title]/page.tsx
+++ b/src/app/billboards/[id]/[title]/page.tsx
@@ -1,24 +1,20 @@
import { BASE_URL } from "@/components/main/BaseUrl";
-import React from "react";
-import { cookies } from "next/headers";
-import Container from "@/components/elements/Container";
+import BillboardDetailContent from "@/components/billboards/BillboardDetailContent";
+import BillboardNotFound from "@/components/billboards/BillboardNotFound";
import { IAdvertising, IRate } from "@/types/types";
-import Image from "next/image";
-import BillboardImageSlider from "@/components/billboards/BillboardPage/BillboardImageSlider";
-import MainBillboardCardActions from "@/components/billboards/MainBillboardCard/MainBillboardCardActions";
-import BillboardDetails from "@/components/billboards/BillboardPage/BillboardDetails";
+import { cookies } from "next/headers";
import { Metadata } from "next";
-
import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import { getLocaleBundle } from "@/lib/i18n/resources";
+import { getServerLanguage } from "@/lib/i18n/server";
interface IBillboardProps {
params: Promise<{ id: string; title: string }>;
}
-// تابع کمکی برای دریافت اطلاعات بیلبورد جهت جلوگیری از تکرار کد در Metadata و Page
async function getBillboardData(id: string, token: string) {
const response = await fetch(`${BASE_URL}/advertising/get/web/${id}`, {
- cache: "no-store",
+ next: { revalidate: 60 },
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
@@ -31,23 +27,25 @@ export async function generateMetadata({
params,
}: IBillboardProps): Promise {
const { id } = await params;
+ const lang = await getServerLanguage();
const token = (await cookies()).get("token")?.value || "";
+ const common = getLocaleBundle(lang).common as {
+ billboards: { notFoundMeta: string };
+ };
const data = await getBillboardData(id, token);
const billboard = data?.advertising as IAdvertising;
if (!billboard) {
- return { title: "بیلبورد یافت نشد | مدستاگرام" };
+ return { title: common.billboards.notFoundMeta };
}
- // ۱. استخراج متغیرها
const title = billboard.title || "";
const category = billboard.category || "";
const province = billboard.province?.name || "";
const city = billboard.city?.name || "";
const neighborhood = billboard.neighbourhood || "";
- // ۲. منطق حذف هوشمند استان: اگر مجموع حروف تایتل و شهر زیاد باشد، استان حذف میشود
const shouldHideProvince = (title.length + category.length + city.length) > 40;
const titleParts = [
@@ -55,21 +53,17 @@ export async function generateMetadata({
category,
!shouldHideProvince ? province : null,
city,
- // جلوگیری از تکرار نام شهر در بخش محله (مثلاً تهران - تهران)
neighborhood !== city ? neighborhood : null,
].filter(Boolean);
- // تایتل نمایشی در مرورگر (جدا شده با خط تیره و فاصله)
const dynamicTitle = titleParts.join(" - ");
- // ۳. هماهنگسازی URL با تایتل (تبدیل تمام اجزا به اسلاگ با خط تیره)
const urlSlug = titleParts
- .join(" ") // ترکیب تمام بخشها با فاصله
- .trim() // حذف فاصلههای اضافی
- .replace(/\s+/g, '-') // تبدیل تمام فاصلهها به خط تیره (-)
- .replace(/-+/g, '-'); // جلوگیری از تکرار خط تیره
+ .join(" ")
+ .trim()
+ .replace(/\s+/g, "-")
+ .replace(/-+/g, "-");
- // ۴. تنظیم دیسکریپشن طبق فرمت: آدرس - توضیحات
const address = billboard.address || `${province} ${city} ${neighborhood}`;
const dynamicDescription = `${address} - ${billboard.description || ""}`.substring(0, 160);
@@ -78,6 +72,7 @@ export async function generateMetadata({
description: dynamicDescription,
path: `/billboards/${id}/${encodeURIComponent(urlSlug)}`,
appendSiteName: true,
+ lang,
});
}
@@ -86,113 +81,15 @@ async function BillboardPage({ params }: IBillboardProps) {
const token = (await cookies()).get("token")?.value || "";
const data = await getBillboardData(id, token);
-
+
if (!data || !data.advertising) {
- return بیلبورد یافت نشد! ;
+ return ;
}
const billboard = data.advertising as IAdvertising;
const rate = data.rate as IRate;
- return (
-
-
-
- {billboard?.title}
-
-
-
-
-
-
-
- {billboard?.category}
-
-
-
-
- <>
- {rate?.adTotalRatings ? rate?.adTotalRatings : "0"}
-
- >
-
-
- <>
-
- {rate?.adAverageRating ? rate?.adAverageRating : "0"}
-
-
- >
-
-
- {billboard?.showDiscount && billboard?.mostDiscountPercentage && (
-
- {billboard?.mostDiscountPercentage}%
-
- )}
-
- {billboard?.images && (
-
- )}
-
-
-
- {/* بخش نمایش مکان بهبود یافته برای سئو و خوانایی */}
-
-
- استان:
- {billboard?.province?.name}
-
-
-
شهر:
- {billboard?.city?.name}
-
-
-
محله:
- {billboard?.neighbourhood}
-
-
-
-
- آدرس:
-
{billboard?.address || "ثبت نشده"}
-
-
-
-
-
- );
+ return ;
}
-export default BillboardPage;
\ No newline at end of file
+export default BillboardPage;
diff --git a/src/app/billboards/loading.tsx b/src/app/billboards/loading.tsx
new file mode 100644
index 0000000..6709112
--- /dev/null
+++ b/src/app/billboards/loading.tsx
@@ -0,0 +1,5 @@
+import BillboardsLoadingContent from "@/components/billboards/BillboardsLoadingContent";
+
+export default function BillboardsLoading() {
+ return ;
+}
diff --git a/src/app/billboards/new/[id]/page.tsx b/src/app/billboards/new/[id]/page.tsx
index 299a6f6..2be3c96 100644
--- a/src/app/billboards/new/[id]/page.tsx
+++ b/src/app/billboards/new/[id]/page.tsx
@@ -7,13 +7,17 @@ import React, { useEffect, useState } from "react";
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
import RoundedDiv from "@/components/elements/RoundedDiv";
import RoundedButton from "@/components/elements/RoundedButton";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { getBillboardDisplayTypeLabel } from "@/lib/billboards/displayTypeLabel";
import { useRouter } from "next/navigation";
+import { useTranslation } from "react-i18next";
interface IBillboardProps {
params: Promise<{ id: string }>;
}
function BillboardPayment({ params }: IBillboardProps) {
+ const { t } = useTranslation("common");
const resolvedParams = React.use(params);
const router = useRouter();
const { request } = useAxios();
@@ -21,6 +25,7 @@ function BillboardPayment({ params }: IBillboardProps) {
const [advertising, setAdvertising] = useState();
const [typeList, setTypeList] = useState(null);
const [price, setPrice] = useState("");
+
useEffect(() => {
const fetchAd = async () => {
const response = await request<{ advertising: IAdvertising }>(
@@ -41,7 +46,7 @@ function BillboardPayment({ params }: IBillboardProps) {
};
fetchAd();
fetchStates();
- }, []);
+ }, [id, request]);
const payHandler = async () => {
try {
@@ -49,20 +54,17 @@ function BillboardPayment({ params }: IBillboardProps) {
"POST",
"/advertising/initiate-payment-web",
{
- item_name: advertising?.type, // Pass advertisingId to initiate payment
+ item_name: advertising?.type,
advertisingId: id,
showDiscount: advertising?.showDiscount,
}
);
- console.log(advertising?.type);
-
-
if (advertising?.type === "free") {
router.push("/settings/my-billboards");
} else {
- const authority = response.authority; // Get the payment authority
- const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`; // Construct payment URL
+ const authority = response.authority;
+ const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
}
} catch (error: unknown) {
@@ -74,42 +76,40 @@ function BillboardPayment({ params }: IBillboardProps) {
if (advertising?.type && typeList) {
const foundType = typeList.find((item) => item.name === advertising.type);
if (foundType) {
- setPrice(String(foundType.price)); // فرض بر اینکه price عددی است و نیاز به تبدیل به رشته دارد
+ setPrice(String(foundType.price));
}
}
}, [advertising, typeList]);
return (
-
-
- پرداخت
-
-
- {advertising &&
}
-
-
- {advertising?.type === "special"
- ? "ویژه"
- : advertising?.type === "normal"
- ? "نمایش ساده"
- : advertising?.type === "free"
- ? "رایگان"
- : "برجسته"}
- : {Number(price).toLocaleString()} تومان
-
-
- مبلغ قابل پرداخت: {Number(price).toLocaleString()} تومان
-
-
- پرداخت
-
+
+
+
+ {t("billboards.payment.title")}
+
+
+ {advertising &&
}
+
+
+ {getBillboardDisplayTypeLabel(t, advertising?.type)}:{" "}
+ {Number(price).toLocaleString()} {t("settings.toman")}
+
+
+ {t("billboards.payment.payableAmount", {
+ amount: Number(price).toLocaleString(),
+ })}
+
+
+ {t("billboards.payment.title")}
+
+
-
-
+
+
);
}
diff --git a/src/app/billboards/new/page.tsx b/src/app/billboards/new/page.tsx
index 48e9f3f..32ebe62 100644
--- a/src/app/billboards/new/page.tsx
+++ b/src/app/billboards/new/page.tsx
@@ -1,15 +1,19 @@
+"use client";
+
import MultiStepForm from "@/components/billboards/NewBillboard/MultiStepForm";
import Container from "@/components/elements/Container";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import { BillboardFormProvider } from "@/contexts/BillboardFormContext";
-import React from "react";
function NewBillboard() {
return (
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/src/app/billboards/payment/failed/page.tsx b/src/app/billboards/payment/failed/page.tsx
index 4d40fdb..57fbdab 100644
--- a/src/app/billboards/payment/failed/page.tsx
+++ b/src/app/billboards/payment/failed/page.tsx
@@ -7,20 +7,24 @@ import React, { useEffect, useState } from "react";
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
import RoundedDiv from "@/components/elements/RoundedDiv";
import RoundedButton from "@/components/elements/RoundedButton";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { getBillboardDisplayTypeLabel } from "@/lib/billboards/displayTypeLabel";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
+import { useTranslation } from "react-i18next";
function FailedBillboard() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const searchParams = useSearchParams();
const projectId = searchParams.get("projectId");
const type = searchParams.get("type");
- console.log({ projectId });
const [advertising, setAdvertising] = useState
();
const [typeList, setTypeList] = useState(null);
const [price, setPrice] = useState("");
+
useEffect(() => {
const fetchAd = async () => {
const response = await request<{ advertising: IAdvertising }>(
@@ -41,7 +45,7 @@ function FailedBillboard() {
};
fetchAd();
fetchStates();
- }, []);
+ }, [projectId, request]);
const payHandler = async () => {
try {
@@ -49,7 +53,7 @@ function FailedBillboard() {
"POST",
"/advertising/initiate-payment-web",
{
- item_name: advertising?.type, // Pass advertisingId to initiate payment
+ item_name: advertising?.type,
advertisingId: projectId,
showDiscount: advertising?.showDiscount,
}
@@ -58,21 +62,22 @@ function FailedBillboard() {
if (advertising?.type === "free") {
router.push("/settings/my-billboards");
} else {
- const authority = response.authority; // Get the payment authority
- const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`; // Construct payment URL
+ const authority = response.authority;
+ const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
}
} catch (error: unknown) {
console.log(error);
}
};
+
const republishHandler = async () => {
try {
const response = await request<{ authority?: string }>(
"POST",
"/advertising/republish-payment/web",
{
- item_name: advertising?.type, // Pass advertisingId to initiate payment
+ item_name: advertising?.type,
advertisingId: projectId,
showDiscount: advertising?.showDiscount,
}
@@ -81,69 +86,70 @@ function FailedBillboard() {
if (type === "free") {
router.push("/settings/my-billboards");
} else {
- const authority = response.authority; // Get the payment authority
- const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`; // Construct payment URL
+ const authority = response.authority;
+ const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
}
} catch (error: unknown) {
console.log(error);
}
};
+
useEffect(() => {
if (advertising?.type && typeList) {
const foundType = typeList.find((item) => item.name === advertising.type);
if (foundType) {
- setPrice(String(foundType.price)); // فرض بر اینکه price عددی است و نیاز به تبدیل به رشته دارد
+ setPrice(String(foundType.price));
}
}
}, [advertising, typeList]);
+
return (
-
-
- پرداخت ناموفق
-
-
-
- پرداخت شما با خطا مواجه شد.
- برای تایید درخواست پرداخت خود را کامل کنید
-
-
- {advertising &&
}
-
-
- {advertising?.type === "special"
- ? "ویژه"
- : advertising?.type === "normal"
- ? "نمایش ساده"
- : advertising?.type === "free"
- ? "رایگان"
- : "برجسته"}
- : {Number(price).toLocaleString()} تومان
-
-
- مبلغ قابل پرداخت: {Number(price).toLocaleString()} تومان
-
-
{
- if (type == "advertising-republish") {
- republishHandler();
- } else {
- payHandler();
- }
- }}
- variant="primary" className="w-32 h-9"
- >
- پرداخت مجدد
-
+
+
+
+ {t("billboards.payment.failed")}
+
+
+
+ {t("billboards.payment.failedHint1")}
+ {t("billboards.payment.failedHint2")}
-
-
+
+ {advertising &&
}
+
+
+ {getBillboardDisplayTypeLabel(t, advertising?.type)}:{" "}
+ {Number(price).toLocaleString()} {t("settings.toman")}
+
+
+ {t("billboards.payment.payableAmount", {
+ amount: Number(price).toLocaleString(),
+ })}
+
+ {
+ if (type == "advertising-republish") {
+ republishHandler();
+ } else {
+ payHandler();
+ }
+ }}
+ variant="primary"
+ className="w-32 h-9"
+ >
+ {t("billboards.payment.retry")}
+
+
+
+
+
);
}
diff --git a/src/app/billboards/payment/success/page.tsx b/src/app/billboards/payment/success/page.tsx
index 569850b..56f19f4 100644
--- a/src/app/billboards/payment/success/page.tsx
+++ b/src/app/billboards/payment/success/page.tsx
@@ -7,11 +7,15 @@ import React, { useEffect, useState } from "react";
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
import RoundedDiv from "@/components/elements/RoundedDiv";
import RoundedButton from "@/components/elements/RoundedButton";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { getBillboardDisplayTypeLabel } from "@/lib/billboards/displayTypeLabel";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
+import { useTranslation } from "react-i18next";
function SuccessBillboard() {
+ const { t } = useTranslation("common");
const { request } = useAxios();
const searchParams = useSearchParams();
const projectId = searchParams.get("projectId");
@@ -19,6 +23,7 @@ function SuccessBillboard() {
const [advertising, setAdvertising] = useState
();
const [typeList, setTypeList] = useState(null);
const [price, setPrice] = useState("");
+
useEffect(() => {
const fetchAd = async () => {
const response = await request<{ advertising: IAdvertising }>(
@@ -39,55 +44,49 @@ function SuccessBillboard() {
};
fetchAd();
fetchStates();
- }, []);
+ }, [projectId, request]);
useEffect(() => {
if (advertising?.type && typeList) {
const foundType = typeList.find((item) => item.name === advertising.type);
if (foundType) {
- setPrice(String(foundType.price)); // فرض بر اینکه price عددی است و نیاز به تبدیل به رشته دارد
+ setPrice(String(foundType.price));
}
}
}, [advertising, typeList]);
+
return (
-
-
- پرداخت موفق
-
-
-
-
-
- {advertising &&
}
-
-
- {advertising?.type === "special"
- ? "ویژه"
- : advertising?.type === "normal"
- ? "نمایش ساده"
- : advertising?.type === "free"
- ? "رایگان"
- : "برجسته"}
- : {Number(price).toLocaleString()} تومان
-
-
- آگهی شما در بیلبورد ثبت شد. پس از بررسی آگهی شما در بیلبورد منتشر
- خواهد شد.
-
-
-
- بیلورد من
-
-
+
+
+
+ {t("billboards.payment.success")}
+
+
+
-
-
+
+ {advertising &&
}
+
+
+ {getBillboardDisplayTypeLabel(t, advertising?.type)}:{" "}
+ {Number(price).toLocaleString()} {t("settings.toman")}
+
+
{t("billboards.payment.registeredMessage")}
+
+
+ {t("billboards.payment.myBillboards")}
+
+
+
+
+
+
);
}
diff --git a/src/app/billboards/profile/[...id]/page.tsx b/src/app/billboards/profile/[...id]/page.tsx
index 52eff0f..90309b5 100644
--- a/src/app/billboards/profile/[...id]/page.tsx
+++ b/src/app/billboards/profile/[...id]/page.tsx
@@ -1,89 +1,80 @@
-import { BASE_URL, IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
-import React, { cache } from "react";
-import { cookies } from "next/headers";
+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 Container from "@/components/elements/Container";
-import AdProfileHead from "@/components/billboards/Profile/AdProfileHead";
-import AdProfileContent from "@/components/billboards/Profile/AdProfileContent";
import { Metadata } from "next";
+import { cache } from "react";
+import { getLocaleBundle } from "@/lib/i18n/resources";
+import { getServerLanguage } from "@/lib/i18n/server";
interface IUserProps {
- params: Promise<{ id: string }>;
+ params: Promise<{ id: string[] }>;
}
-// بهینهسازی واکشی دادهها برای جلوگیری از تکرار درخواست (Shared Cache)
const getProfile = cache(async (id: string) => {
try {
const res = await fetch(`${BASE_URL}/advertising/profile-web?vitrineId=${id}`, {
- cache: 'no-store'
+ cache: "no-store",
});
if (!res.ok) return null;
const data = await res.json();
return data.profileDetails as IAdvertisingProfile;
- } catch (error) {
+ } catch {
return null;
}
});
export async function generateMetadata({ params }: IUserProps): Promise
{
- const { id } = await params;
+ const { id: idSegments } = await params;
+ const id = idSegments?.[0] || "";
+ const lang = await getServerLanguage();
+ const common = getLocaleBundle(lang).common as {
+ billboards: { adNotFoundMeta: string };
+ };
const profile = await getProfile(id);
if (!profile) {
- return { title: "آگهی یافت نشد | مدستاگرام" };
+ return { title: common.billboards.adNotFoundMeta };
}
- // --- تنظیم تایتل طبق فرمت درخواستی شما ---
- // فرمت: عنوان آگهی - دسته بندی - محله - شهر - مدستاگرام
const titleParts = [
- profile.title,
- profile.category?.name,
- profile.neighborhood?.name,
+ profile.vitrine_name,
+ profile.category,
+ profile.neighbourhood,
profile.city?.name,
- "مدستاگرام"
- ].filter(Boolean); // حذف مقادیر خالی
+ "Modstagram",
+ ].filter(Boolean);
const finalTitle = titleParts.join(" - ");
-
- // --- تنظیم دیسکریپشن طبق فرمت درخواستی شما ---
- // فرمت: دسته بندی - شهر - توضیحات آگهی
- const description = `${profile.category?.name || ""} - ${profile.city?.name || ""} - ${profile.description || ""}`.slice(0, 160);
-
- const image = profile.images?.[0] ? `${IMAGE_BASE_URL}${profile.images[0]}` : "/images/logo.png";
+ const description = `${profile.category || ""} - ${profile.city?.name || ""} - ${profile.about_us || ""}`.slice(0, 160);
+ const image = profile.allImages?.[0]
+ ? `${IMAGE_BASE_URL}${profile.allImages[0]}`
+ : "/images/logo.png";
return {
title: finalTitle,
- description: description,
+ description,
openGraph: {
title: finalTitle,
- description: description,
+ description,
url: `https://modstagram.com/billboards/${id}`,
- siteName: "مدستاگرام",
+ siteName: "Modstagram",
images: [{ url: image }],
- locale: "fa_IR",
+ locale: lang === "fa" ? "fa_IR" : "en_US",
type: "article",
},
alternates: {
canonical: `https://modstagram.com/billboards/${id}`,
- }
+ },
};
}
export default async function AdvertisingProfilePage({ params }: IUserProps) {
- const { id } = await params;
+ const { id: idSegments } = await params;
+ const id = idSegments?.[0] || "";
const profile = await getProfile(id);
- if (!profile) return آگهی یافت نشد ;
+ if (!profile) return ;
- return (
-
- {/* اضافه کردن H1 برای سبز شدن نمره سئو - مخفی از کاربر بصری، مرئی برای گوگل */}
-
- {profile.title} - {profile.category?.name} در {profile.city?.name}، {profile.neighborhood?.name}
-
-
-
-
-
- );
-}
\ No newline at end of file
+ return ;
+}
diff --git a/src/app/billboards/profile/[id]/[title]/page.tsx b/src/app/billboards/profile/[id]/[title]/page.tsx
index 52eff0f..00bea91 100644
--- a/src/app/billboards/profile/[id]/[title]/page.tsx
+++ b/src/app/billboards/profile/[id]/[title]/page.tsx
@@ -1,71 +1,70 @@
-import { BASE_URL, IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
-import React, { cache } from "react";
-import { cookies } from "next/headers";
+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 Container from "@/components/elements/Container";
-import AdProfileHead from "@/components/billboards/Profile/AdProfileHead";
-import AdProfileContent from "@/components/billboards/Profile/AdProfileContent";
import { Metadata } from "next";
+import { cache } from "react";
+import { getLocaleBundle } from "@/lib/i18n/resources";
+import { getServerLanguage } from "@/lib/i18n/server";
interface IUserProps {
params: Promise<{ id: string }>;
}
-// بهینهسازی واکشی دادهها برای جلوگیری از تکرار درخواست (Shared Cache)
const getProfile = cache(async (id: string) => {
try {
const res = await fetch(`${BASE_URL}/advertising/profile-web?vitrineId=${id}`, {
- cache: 'no-store'
+ cache: "no-store",
});
if (!res.ok) return null;
const data = await res.json();
return data.profileDetails as IAdvertisingProfile;
- } catch (error) {
+ } catch {
return null;
}
});
export async function generateMetadata({ params }: IUserProps): Promise {
const { id } = await params;
+ const lang = await getServerLanguage();
+ const common = getLocaleBundle(lang).common as {
+ billboards: { adNotFoundMeta: string };
+ };
const profile = await getProfile(id);
if (!profile) {
- return { title: "آگهی یافت نشد | مدستاگرام" };
+ return { title: common.billboards.adNotFoundMeta };
}
- // --- تنظیم تایتل طبق فرمت درخواستی شما ---
- // فرمت: عنوان آگهی - دسته بندی - محله - شهر - مدستاگرام
const titleParts = [
- profile.title,
- profile.category?.name,
- profile.neighborhood?.name,
+ profile.vitrine_name,
+ profile.category,
+ profile.neighbourhood,
profile.city?.name,
- "مدستاگرام"
- ].filter(Boolean); // حذف مقادیر خالی
+ "Modstagram",
+ ].filter(Boolean);
const finalTitle = titleParts.join(" - ");
-
- // --- تنظیم دیسکریپشن طبق فرمت درخواستی شما ---
- // فرمت: دسته بندی - شهر - توضیحات آگهی
- const description = `${profile.category?.name || ""} - ${profile.city?.name || ""} - ${profile.description || ""}`.slice(0, 160);
-
- const image = profile.images?.[0] ? `${IMAGE_BASE_URL}${profile.images[0]}` : "/images/logo.png";
+ const description = `${profile.category || ""} - ${profile.city?.name || ""} - ${profile.about_us || ""}`.slice(0, 160);
+ const image = profile.allImages?.[0]
+ ? `${IMAGE_BASE_URL}${profile.allImages[0]}`
+ : "/images/logo.png";
return {
title: finalTitle,
- description: description,
+ description,
openGraph: {
title: finalTitle,
- description: description,
+ description,
url: `https://modstagram.com/billboards/${id}`,
- siteName: "مدستاگرام",
+ siteName: "Modstagram",
images: [{ url: image }],
- locale: "fa_IR",
+ locale: lang === "fa" ? "fa_IR" : "en_US",
type: "article",
},
alternates: {
canonical: `https://modstagram.com/billboards/${id}`,
- }
+ },
};
}
@@ -73,17 +72,7 @@ export default async function AdvertisingProfilePage({ params }: IUserProps) {
const { id } = await params;
const profile = await getProfile(id);
- if (!profile) return آگهی یافت نشد ;
+ if (!profile) return ;
- return (
-
- {/* اضافه کردن H1 برای سبز شدن نمره سئو - مخفی از کاربر بصری، مرئی برای گوگل */}
-
- {profile.title} - {profile.category?.name} در {profile.city?.name}، {profile.neighborhood?.name}
-
-
-
-
-
- );
-}
\ No newline at end of file
+ return ;
+}
diff --git a/src/app/explore/[id]/page.tsx b/src/app/explore/[id]/page.tsx
index c45966d..f5a880a 100644
--- a/src/app/explore/[id]/page.tsx
+++ b/src/app/explore/[id]/page.tsx
@@ -8,6 +8,8 @@ import {
buildPostSeoTitle,
} from "@/lib/postSlug";
import ExploreReelClient from "./ExploreReelClient";
+import { getServerLanguage } from "@/lib/i18n/server";
+import { translateCommon } from "@/lib/i18n/translate";
type Props = {
params: Promise<{ id: string }>;
@@ -15,11 +17,14 @@ type Props = {
export async function generateMetadata({ params }: Props): Promise {
const { id } = await params;
+ const lang = await getServerLanguage();
const post = await fetchPostById(id);
- const title = post ? buildPostSeoTitle(post) : "پست | مدستاگرام";
+ const title = post
+ ? buildPostSeoTitle(post, lang)
+ : translateCommon(lang, "exploreMeta.defaultTitle");
const description = post
- ? buildPostSeoDescription(post)
- : "مشاهده پست در اکسپلور مدستاگرام";
+ ? buildPostSeoDescription(post, lang)
+ : translateCommon(lang, "exploreMeta.defaultDescription");
const ogImage = post?.files?.[0]?.path
? buildStorageUrl(post.files[0].path)
: undefined;
@@ -30,9 +35,10 @@ export async function generateMetadata({ params }: Props): Promise {
path: buildPostPath(id, post ?? undefined),
type: "article",
imageUrl: ogImage,
- imageAlt: post?.caption?.slice(0, 80) || "پست مدستاگرام",
+ imageAlt: post?.caption?.slice(0, 80) || translateCommon(lang, "exploreMeta.defaultAlt"),
publishedTime: post?.createdAt,
modifiedTime: post?.updatedAt,
+ lang,
});
}
diff --git a/src/app/explore/layout.tsx b/src/app/explore/layout.tsx
index 753759c..729d1b4 100644
--- a/src/app/explore/layout.tsx
+++ b/src/app/explore/layout.tsx
@@ -1,13 +1,9 @@
import type { Metadata } from "next";
-import { pageSeo } from "@/config/pageSeo";
-import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
-export const metadata: Metadata = generatePageMetadata({
- title: pageSeo.explore.title,
- description: pageSeo.explore.description,
- path: pageSeo.explore.path,
- keywords: [...pageSeo.explore.keywords],
-});
+export async function generateMetadata(): Promise {
+ return generateSeoPageMetadata("explore");
+}
export default function ExploreLayout({
children,
diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx
index 46afa52..293abc8 100644
--- a/src/app/explore/page.tsx
+++ b/src/app/explore/page.tsx
@@ -5,10 +5,13 @@ import TabNavigation from "@/components/TabNavigation";
import ExploreFilterBar from "@/components/explore/ExploreFilterBar";
import ExploreGrid from "@/components/explore/ExploreGrid";
import Header from "@/components/main/Header";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import { ExploreFilterId } from "@/constants/exploreFilters";
-import { pageSeo } from "@/config/pageSeo";
+import { getSeoPage } from "@/lib/i18n/seo";
+import { useAppLanguage } from "@/contexts/LanguageProvider";
import { useCallback, useState } from "react";
import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
function parseSearchQuery(input: string): { q?: string; hashtag?: string } {
const trimmed = input.trim();
@@ -21,6 +24,8 @@ function parseSearchQuery(input: string): { q?: string; hashtag?: string } {
}
export default function ExplorePage() {
+ const { t } = useTranslation("common");
+ const { language } = useAppLanguage();
const [activeFilter, setActiveFilter] = useState("all");
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(
null
@@ -41,7 +46,7 @@ export default function ExplorePage() {
const handleFilterChange = useCallback((filter: ExploreFilterId) => {
if (filter === "near_me") {
if (!navigator.geolocation) {
- toast.error("مرورگر شما از موقعیت مکانی پشتیبانی نمیکند.");
+ toast.error(t("explore.geoNotSupported"));
return;
}
@@ -57,9 +62,7 @@ export default function ExplorePage() {
},
() => {
setLoadingLocation(false);
- toast.error(
- "برای نمایش پستهای نزدیک، دسترسی به موقعیت مکانی لازم است."
- );
+ toast.error(t("explore.geoPermissionRequired"));
},
{ enableHighAccuracy: true, timeout: 15000 }
);
@@ -70,29 +73,33 @@ export default function ExplorePage() {
if (filter !== "near_me") {
setCoords(null);
}
- }, []);
+ }, [t]);
+
+ const exploreSeo = getSeoPage("explore", language);
return (
<>
-
-
- {pageSeo.explore.title.replace(" | مدستاگرام", "")}
-
-
-
-
+
+
+ {exploreSeo.title.split("|")[0].trim()}
+
+
+
+
+
+
>
);
diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx
new file mode 100644
index 0000000..4741c40
--- /dev/null
+++ b/src/app/global-error.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import i18n from "@/lib/i18n";
+import { readStoredLanguagePreference, resolveBrowserLanguage } from "@/lib/i18n/clientLanguage";
+
+export default function GlobalError({
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ const [lang, setLang] = useState(readStoredLanguagePreference() ?? resolveBrowserLanguage());
+
+ useEffect(() => {
+ const stored = readStoredLanguagePreference() ?? resolveBrowserLanguage();
+ setLang(stored);
+ void i18n.changeLanguage(stored);
+ }, []);
+
+ const t = (key: string) => i18n.t(key, { lng: lang, ns: "common" });
+ const dir = lang === "fa" ? "rtl" : "ltr";
+
+ return (
+
+
+ {t("errors.appLoad")}
+ {t("errors.safariIosHint")}
+ reset()}
+ className="rounded-xl bg-[#0095f6] px-5 py-2.5 text-sm font-bold text-white"
+ >
+ {t("errors.retry")}
+
+
+
+ );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 0ada612..751e860 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -661,6 +661,15 @@ select {
animation: check-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
+@keyframes story-ring-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+.story-ring-live {
+ animation: story-ring-spin 3s linear infinite;
+}
+
/* Message enter — handled by framer-motion in ChatMessageCard */
/* ─── Shrink header ─── */
@@ -771,4 +780,18 @@ select {
}
.glass-modal-panel--center {
border-radius: 1.5rem;
+}
+
+/* English LTR page shells (applied page-by-page later) */
+.locale-page-shell--ltr {
+ direction: ltr;
+ text-align: left;
+}
+
+html[dir="ltr"] body {
+ direction: ltr;
+}
+
+html[dir="ltr"] .rtl\:flex-row-reverse {
+ flex-direction: row;
}
\ No newline at end of file
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 7ba2a0f..c7fd1b7 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,76 +1,104 @@
-import type { Metadata } from "next";
+import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import { defaultSEOConfig } from "@/config/seoConfig";
-import { pageSeo, siteKeywords } from "@/config/pageSeo";
+import { getSiteSeoMeta, getSeoPage } from "@/lib/i18n/seo";
+import { getLanguageDefinition } from "@/lib/i18n/registry";
+import { getServerLanguage } from "@/lib/i18n/server";
import SiteJsonLd from "@/components/seo/SiteJsonLd";
import "./globals.css";
import Layout from "@/components/Layout";
import RegisterSW from "@/components/RegisterSW";
+import ClientErrorBoundary from "@/components/ClientErrorBoundary";
const iranSansFont = localFont({
src: "./../../public/fonts/IRANSansX-Regular.woff",
});
-const defaultTitle = pageSeo.home.title;
+export async function generateMetadata(): Promise {
+ const lang = await getServerLanguage();
+ const site = getSiteSeoMeta(lang);
+ const home = getSeoPage("home", lang);
-export const metadata: Metadata = {
- title: {
- default: defaultTitle,
- template: "%s",
- },
- description: pageSeo.home.description,
- metadataBase: new URL("https://modstagram.com"),
- keywords: siteKeywords,
- authors: [{ name: "Modstagram", url: "https://modstagram.com" }],
- creator: "Modstagram",
- manifest: "/manifest.json",
- icons: {
- icon: [{ url: "/favicon.ico", type: "image/x-icon" }],
- shortcut: ["/favicon.ico"],
- apple: [
- { url: "/images/icons/apple-touch-icon.png" },
- { url: "/images/icons/192x192.png" },
- ],
- },
- appleWebApp: {
- capable: true,
- statusBarStyle: "black",
- },
- openGraph: {
- siteName: defaultSEOConfig.openGraph?.site_name || "مدستاگرام",
- locale: "fa_IR",
- type: "website",
- images: defaultSEOConfig.openGraph?.images || [],
- },
- robots: "index, follow",
- twitter: {
- card: "summary_large_image",
- creator: "@modstagram",
- site: "@modstagram",
- images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
- },
- verification: {
- google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
- },
- other: {
- "theme-color": "#ffffff",
- "msapplication-TileColor": "#0072BC",
- samandehi: "522190795",
- },
+ return {
+ title: {
+ default: site.name,
+ template: `%s | ${site.titleSuffix}`,
+ },
+ description: home.description,
+ metadataBase: new URL("https://modstagram.com"),
+ keywords: site.keywords,
+ authors: [{ name: "Modstagram", url: "https://modstagram.com" }],
+ creator: "Modstagram",
+ manifest: "/manifest.webmanifest",
+ icons: {
+ icon: [
+ { url: "/favicon.png", type: "image/png", sizes: "32x32" },
+ { url: "/images/icons/192x192.png", type: "image/png", sizes: "192x192" },
+ ],
+ shortcut: ["/favicon.png"],
+ apple: [
+ { url: "/images/icons/apple-touch-icon.png", sizes: "180x180" },
+ { url: "/images/icons/192x192.png", sizes: "192x192" },
+ ],
+ },
+ appleWebApp: {
+ capable: true,
+ statusBarStyle: "default",
+ title: site.name,
+ },
+ openGraph: {
+ siteName: site.name,
+ locale: lang === "fa" ? "fa_IR" : "en_US",
+ type: "website",
+ images: defaultSEOConfig.openGraph?.images || [],
+ },
+ robots: "index, follow",
+ twitter: {
+ card: "summary_large_image",
+ creator: "@modstagram",
+ site: "@modstagram",
+ images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
+ },
+ verification: {
+ google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
+ },
+ other: {
+ "theme-color": "#FF107D",
+ "mobile-web-app-capable": "yes",
+ "msapplication-TileColor": "#FF107D",
+ samandehi: "522190795",
+ },
+ };
+}
+
+export const viewport: Viewport = {
+ width: "device-width",
+ initialScale: 1,
+ maximumScale: 1,
+ viewportFit: "cover",
+ themeColor: [
+ { media: "(prefers-color-scheme: light)", color: "#ffffff" },
+ { media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
+ ],
};
-export default function RootLayout({
+export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
+ const lang = await getServerLanguage();
+ const def = getLanguageDefinition(lang);
+
return (
-
+
- {children}
+
+ {children}
+
);
diff --git a/src/app/loading.tsx b/src/app/loading.tsx
index bff941a..e6a8f82 100644
--- a/src/app/loading.tsx
+++ b/src/app/loading.tsx
@@ -1,10 +1,14 @@
import IOSSpinner from "@/components/ui/IOSSpinner";
+import { getServerLanguage } from "@/lib/i18n/server";
+import { translateCommon } from "@/lib/i18n/translate";
+
+export default async function Loading() {
+ const lang = await getServerLanguage();
-export default function Loading() {
return (
- در حال بارگذاری…
+ {translateCommon(lang, "common.loading")}
);
}
diff --git a/src/app/manifest.webmanifest/route.ts b/src/app/manifest.webmanifest/route.ts
new file mode 100644
index 0000000..eac8fab
--- /dev/null
+++ b/src/app/manifest.webmanifest/route.ts
@@ -0,0 +1,42 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ buildWebManifest,
+ PWA_VERSION,
+ toPwaLang,
+} from "@/lib/pwaConfig";
+import {
+ DEFAULT_LANGUAGE,
+ isAppLanguage,
+ LANGUAGE_COOKIE,
+} from "@/lib/i18n/registry";
+import { resolveLanguageFromAcceptHeader } from "@/lib/i18n/resolveLanguage";
+
+export const dynamic = "force-dynamic";
+
+function resolveLangFromRequest(request: NextRequest) {
+ const queryLang = request.nextUrl.searchParams.get("lang");
+ if (isAppLanguage(queryLang)) return queryLang;
+
+ const cookieLang = request.cookies.get(LANGUAGE_COOKIE)?.value;
+ if (isAppLanguage(cookieLang)) return cookieLang;
+
+ const fromAccept = resolveLanguageFromAcceptHeader(
+ request.headers.get("accept-language")
+ );
+ if (fromAccept) return fromAccept;
+
+ return DEFAULT_LANGUAGE;
+}
+
+export async function GET(request: NextRequest) {
+ const lang = toPwaLang(resolveLangFromRequest(request));
+ const manifest = buildWebManifest(lang);
+
+ return NextResponse.json(manifest, {
+ headers: {
+ "Content-Type": "application/manifest+json; charset=utf-8",
+ "Cache-Control": "no-cache, no-store, must-revalidate",
+ "X-PWA-Version": PWA_VERSION,
+ },
+ });
+}
diff --git a/src/app/new-post/layout.tsx b/src/app/new-post/layout.tsx
index 574c282..c4a679a 100644
--- a/src/app/new-post/layout.tsx
+++ b/src/app/new-post/layout.tsx
@@ -3,12 +3,11 @@ import TabNavigation from "@/components/TabNavigation";
import { Suspense } from "react";
import PageLoader from "@/components/ui/PageLoader";
import type { Metadata } from "next";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
-export const metadata: Metadata = {
- title: "انتشار پست | مدستاگرام",
- description: "انتشار پست، ویدئو یا استوری در مدستاگرام",
- robots: { index: false, follow: false },
-};
+export async function generateMetadata(): Promise {
+ return generateSeoPageMetadata("newPost", { index: false });
+}
export default function Layout({ children }: { children: React.ReactNode }) {
return (
diff --git a/src/app/new-post/page.tsx b/src/app/new-post/page.tsx
index f0fb593..6b179f6 100644
--- a/src/app/new-post/page.tsx
+++ b/src/app/new-post/page.tsx
@@ -7,20 +7,37 @@ import { optimizeImageToWebP, optimizeVideoToMp4 } from "@/lib/media";
import { createStoryBase64, StoryTextOverlay } from "@/api/fetchStories";
import StoryEditor from "@/components/stories/StoryEditor";
import TagUsersPicker, { TaggedUser } from "@/components/posts/TagUsersPicker";
+import PostLinkPicker from "@/components/posts/PostLinkPicker";
+import {
+ SelectedPostLink,
+} from "@/lib/postLinkCaption";
+import {
+ buildFullPostCaption,
+ countPostCaptionLength,
+ POST_CAPTION_MAX_LENGTH,
+} from "@/lib/captionLength";
+import PostLocationNoticeModal from "@/components/posts/PostLocationNoticeModal";
+import PostExpertiseRequiredModal from "@/components/posts/PostExpertiseRequiredModal";
+import VideoCoverPicker from "@/components/posts/VideoCoverPicker";
import BoldIcon from "@/components/ui/BoldIcon";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { useUser } from "@/hooks/useUser";
+import {
+ userHasExpertise,
+} from "@/lib/postExpertise";
+import {
+ hasSeenPostLocationNotice,
+ markPostLocationNoticeSeen,
+} from "@/lib/postLocationNotice";
import Cookies from "js-cookie";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
+import { User } from "@/types/types";
type CreateMode = "image" | "video" | "story";
-const MODE_LABELS: Record = {
- image: "عکس",
- video: "ویدئو",
- story: "استوری",
-};
-
function fileToBase64(file: File): Promise {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -31,9 +48,12 @@ function fileToBase64(file: File): Promise {
}
export default function NewPostPage() {
+ const { t, i18n } = useTranslation("common");
+ const captionLang = i18n.language === "en" ? "en" : "fa";
const router = useRouter();
const searchParams = useSearchParams();
const { request, loading } = useAxios();
+ const user = useUser();
const initialMode = (searchParams.get("type") as CreateMode) || "image";
const [mode, setMode] = useState(
@@ -45,7 +65,28 @@ export default function NewPostPage() {
const [extraPreviews, setExtraPreviews] = useState([""]);
const [description, setDescription] = useState("");
const [taggedUsers, setTaggedUsers] = useState([]);
+ const [selectedPackages, setSelectedPackages] = useState(
+ []
+ );
+ const [selectedProjects, setSelectedProjects] = useState(
+ []
+ );
+ const [selectedBillboards, setSelectedBillboards] = useState<
+ SelectedPostLink[]
+ >([]);
const [storyOverlays, setStoryOverlays] = useState([]);
+ const [videoCover, setVideoCover] = useState(null);
+ const [locationNoticeOpen, setLocationNoticeOpen] = useState(false);
+ const [expertiseModalOpen, setExpertiseModalOpen] = useState(false);
+
+ const modeLabels: Record = useMemo(
+ () => ({
+ image: t("posts.modeImage"),
+ video: t("posts.modeVideo"),
+ story: t("posts.modeStory"),
+ }),
+ [t]
+ );
useEffect(() => {
const t = searchParams.get("type") as CreateMode;
@@ -60,6 +101,7 @@ export default function NewPostPage() {
setExtraImages([null]);
setExtraPreviews([""]);
setStoryOverlays([]);
+ setVideoCover(null);
};
const switchMode = (next: CreateMode) => {
@@ -77,11 +119,11 @@ export default function NewPostPage() {
if (!picked) return;
if (mode === "video" && !picked.type.startsWith("video/")) {
- toast.error("فقط ویدئو مجاز است");
+ toast.error(t("posts.videoOnly"));
return;
}
if (mode === "image" && !picked.type.startsWith("image/")) {
- toast.error("فقط تصویر مجاز است");
+ toast.error(t("posts.imageOnly"));
return;
}
@@ -93,7 +135,7 @@ export default function NewPostPage() {
const onPickExtra = (index: number) => (e: React.ChangeEvent) => {
const picked = e.target.files?.[0];
if (!picked || !picked.type.startsWith("image/")) {
- toast.error("فقط تصویر مجاز است");
+ toast.error(t("posts.imageOnly"));
return;
}
const files = [...extraImages];
@@ -108,20 +150,50 @@ export default function NewPostPage() {
}
};
+ const returnPath = useMemo(() => {
+ const params = new URLSearchParams();
+ if (mode !== "image") params.set("type", mode);
+ const qs = params.toString();
+ return qs ? `/new-post?${qs}` : "/new-post";
+ }, [mode]);
+
+ const resolveProfileExpertise = async (): Promise => {
+ if (userHasExpertise(user)) return user;
+ try {
+ const response = await request<{ user: User }>(
+ "GET",
+ "/profile",
+ null,
+ { noToast: true }
+ );
+ return response?.user;
+ } catch {
+ return user;
+ }
+ };
+
+ const ensurePostExpertise = async (): Promise => {
+ const profile = await resolveProfileExpertise();
+ if (userHasExpertise(profile)) return true;
+ toast.error(t("posts.expertiseRequired"));
+ setExpertiseModalOpen(true);
+ return false;
+ };
+
const publish = async () => {
const token = Cookies.get("token");
if (!token) {
- toast.error("لطفاً وارد حساب کاربری شوید");
+ toast.error(t("posts.loginToPost"));
return;
}
if (mode === "story") {
if (!file) {
- toast.error("انتخاب عکس یا ویدئو برای استوری الزامی است");
+ toast.error(t("posts.storyMediaRequired"));
return;
}
try {
- toast.loading("در حال انتشار استوری…", { id: "story-upload" });
+ toast.loading(t("posts.publishingStory"), { id: "story-upload" });
let optimized: File;
if (file.type.startsWith("video/")) {
// اگر تبدیل ویدیو (ffmpeg) در دسترس نبود، فایل اصلی ارسال میشود
@@ -146,34 +218,35 @@ export default function NewPostPage() {
token,
storyOverlays
);
- toast.success("استوری منتشر شد", { id: "story-upload" });
+ toast.success(t("posts.storyPublished"), { id: "story-upload" });
router.push("/");
} catch (err) {
- toast.error(err instanceof Error ? err.message : "خطا در انتشار استوری", {
+ toast.error(err instanceof Error ? err.message : t("posts.storyPublishError"), {
id: "story-upload",
});
}
return;
}
+ if (!(await ensurePostExpertise())) {
+ return;
+ }
+
const imageFiles =
mode === "image"
- ? [...(file ? [file] : []), ...extraImages.filter(Boolean)] as File[]
- : file
- ? [file]
- : [];
+ ? ([...(file ? [file] : []), ...extraImages.filter(Boolean)] as File[])
+ : mode === "video" && file
+ ? ([...(videoCover ? [videoCover] : []), file] as File[])
+ : file
+ ? [file]
+ : [];
if (!imageFiles.length) {
- toast.error("انتخاب مدیا الزامی است");
+ toast.error(t("posts.mediaRequired"));
return;
}
- if (!description.trim()) {
- toast.error("نوشتن کپشن الزامی است");
- return;
- }
-
try {
- toast.loading("در حال آپلود…", { id: "post-upload" });
+ toast.loading(t("posts.uploading"), { id: "post-upload" });
const optimizedFiles = await Promise.all(
imageFiles.map(async (f) =>
f.type.startsWith("video/")
@@ -188,26 +261,89 @@ export default function NewPostPage() {
data: await fileToBase64(f),
}))
);
- const tagLine = taggedUsers.map((u) => `@${u.user_name}`).join(" ");
- const caption = [description, tagLine].filter(Boolean).join("\n");
+ const caption = buildFullPostCaption(
+ description,
+ taggedUsers,
+ selectedPackages,
+ selectedProjects,
+ selectedBillboards,
+ captionLang
+ );
+
+ if (caption.length > POST_CAPTION_MAX_LENGTH) {
+ toast.error(
+ t("posts.captionTooLong", { max: POST_CAPTION_MAX_LENGTH }),
+ {
+ id: "post-upload",
+ }
+ );
+ return;
+ }
await request("POST", "/posts/create-base64", {
files: filesBase64,
caption,
tagged_user_ids: taggedUsers.map((u) => u._id),
- });
- toast.success("پست منتشر شد", { id: "post-upload" });
+ linked_course_id: selectedPackages[0]?._id ?? null,
+ linked_project_id: selectedProjects[0]?._id ?? null,
+ linked_billboard_id: selectedBillboards[0]?._id ?? null,
+ }, { noToast: true });
+ toast.success(t("posts.postPublished"), { id: "post-upload" });
router.push("/");
- } catch (err) {
- toast.error(err instanceof Error ? err.message : "خطا در آپلود", {
+ } catch (err: unknown) {
+ const axiosErr = err as {
+ response?: { data?: { type?: string; message?: string } };
+ };
+ if (axiosErr.response?.data?.type === "expertise_required") {
+ toast.error(
+ axiosErr.response.data.message || t("posts.expertiseRequired"),
+ { id: "post-upload" }
+ );
+ setExpertiseModalOpen(true);
+ return;
+ }
+ toast.error(err instanceof Error ? err.message : t("posts.uploadError"), {
id: "post-upload",
});
}
};
+ const locationSharingEnabled = user?.post_location_enabled === true;
+
+ const handleShareClick = async () => {
+ if (!isStory && !(await ensurePostExpertise())) {
+ return;
+ }
+
+ if (
+ !isStory &&
+ locationSharingEnabled &&
+ !hasSeenPostLocationNotice()
+ ) {
+ setLocationNoticeOpen(true);
+ return;
+ }
+ void publish();
+ };
+
+ const dismissLocationNotice = () => {
+ markPostLocationNoticeSeen();
+ setLocationNoticeOpen(false);
+ };
+
+ const captionLength = countPostCaptionLength(
+ description,
+ taggedUsers,
+ selectedPackages,
+ selectedProjects,
+ selectedBillboards,
+ captionLang
+ );
+
const isStory = mode === "story";
return (
+
{/* Header — Instagram style */}
@@ -215,20 +351,20 @@ export default function NewPostPage() {
type="button"
onClick={() => router.back()}
className="flex h-9 w-9 items-center justify-center"
- aria-label="بازگشت"
+ aria-label={t("common.back")}
>
- {isStory ? "استوری جدید" : "پست جدید"}
+ {isStory ? t("posts.newStory") : t("posts.newPost")}
- {loading ? "…" : "اشتراک"}
+ {loading ? "…" : t("posts.shareButton")}
@@ -245,7 +381,7 @@ export default function NewPostPage() {
: "text-neutral-400"
}`}
>
- {MODE_LABELS[m]}
+ {modeLabels[m]}
))}
@@ -260,7 +396,7 @@ export default function NewPostPage() {
onChange={setStoryOverlays}
/>
- تغییر فایل
+ {t("posts.changeFile")}
{isStory
- ? "عکس یا ویدئو استوری را انتخاب کنید"
+ ? t("posts.pickStoryMedia")
: mode === "video"
- ? "ویدئو را انتخاب کنید"
- : "عکس را انتخاب کنید"}
+ ? t("posts.pickVideo")
+ : t("posts.pickImage")}
- انتخاب از گالری
+ {t("posts.pickFromGallery")}
- تغییر فایل
+ {t("posts.changeFile")}
)}
@@ -359,30 +495,58 @@ export default function NewPostPage() {
)}
+ {mode === "video" && preview && file?.type.startsWith("video/") && (
+
+ )}
+
{/* Caption — not for story */}
{!isStory && (
{
- if (e.target.value.length <= 1000) setDescription(e.target.value);
- }}
- rows={3}
- className="w-full resize-none bg-transparent text-sm outline-none placeholder:text-neutral-400"
+ onChange={(e) => setDescription(e.target.value)}
+ rows={5}
+ className="min-h-[7.5rem] w-full resize-none whitespace-pre-wrap bg-transparent text-sm outline-none placeholder:text-neutral-400"
/>
- {description.length}/1000
+
+ {captionLength}/{POST_CAPTION_MAX_LENGTH}
+
+
)}
{isStory && (
- استوری شما ۲۴ ساعت نمایش داده میشود و سپس بهطور خودکار حذف میگردد.
+ {t("posts.storyExpiryHint")}
)}
+
+ {
+ dismissLocationNotice();
+ void publish();
+ }}
+ onYes={dismissLocationNotice}
+ />
+
+ setExpertiseModalOpen(false)}
+ returnPath={returnPath}
+ />
+
);
}
diff --git a/src/app/offer/[id]/page.tsx b/src/app/offer/[id]/page.tsx
index aed9f06..f7b4ebf 100644
--- a/src/app/offer/[id]/page.tsx
+++ b/src/app/offer/[id]/page.tsx
@@ -18,23 +18,23 @@ import React, { useState, useEffect } from "react";
import toast from "react-hot-toast";
import Cookies from "js-cookie";
import * as Yup from "yup";
-
-
-
-const validationSchema = Yup.object({
- selectedType: Yup.string().required(" انتخاب کردن نوع درخواست الزامی است"),
-});
+import { useTranslation } from "react-i18next";
interface ITicketChatProps {
params: Promise<{ id: string; title: string }>;
}
function TicketChat({ params }: ITicketChatProps) {
+ const { t } = useTranslation("common");
const resolvedParams = React.use(params);
const { request } = useAxios();
const { id } = resolvedParams;
const router = useRouter();
+ const validationSchema = Yup.object({
+ selectedType: Yup.string().required(t("offerPage.typeRequired")),
+ });
+
const [userDetail, setUserDetail] = useState();
const [selectedType, setSelectedType] = useState("normal");
const [typeList, setTypeList] = useState(null);
@@ -74,7 +74,7 @@ function TicketChat({ params }: ITicketChatProps) {
validationSchema,
onSubmit: async (values, { setSubmitting }) => {
if (values.selectedType === "free" && hasUsedFreeOffer) {
- toast.error("شما قبلاً از درخواست رایگان استفاده کردهاید!");
+ toast.error(t("offerPage.freeAlreadyUsed"));
return;
}
@@ -104,7 +104,7 @@ function TicketChat({ params }: ITicketChatProps) {
console.log("Authority:", data.authority);
console.log("Response:", response);
if (values.selectedType === "free") {
- toast.success("درخواست رایگان شما ثبت شد!");
+ toast.success(t("offerPage.freeSuccess"));
setHasUsedFreeOffer(true);
router.push(`/offer/payment/success?userId=${id}`);
}
@@ -115,7 +115,7 @@ function TicketChat({ params }: ITicketChatProps) {
console.log("Redirecting to:", paymentUrl);
window.open(paymentUrl, "_blank");
} else {
- toast.error("خطا در دریافت اطلاعات پرداخت");
+ toast.error(t("offerPage.paymentInfoError"));
}
@@ -123,7 +123,7 @@ function TicketChat({ params }: ITicketChatProps) {
} catch (err: any) {
console.error("Payment initiation error:", err);
- toast.error(err?.response?.data?.error || "خطا در شروع پرداخت");
+ toast.error(err?.response?.data?.error || t("offerPage.paymentStartError"));
} finally {
setSubmitting(false);
}
@@ -135,9 +135,9 @@ function TicketChat({ params }: ITicketChatProps) {
-
پرداخت
+
{t("offerPage.paymentTitle")}
- شما فقط 1 بار میتوانید درخواست رایگان ثبت کنید
+ {t("offerPage.freeOnceHint")}
@@ -184,7 +184,7 @@ function TicketChat({ params }: ITicketChatProps) {
width={21}
height={21}
alt={"verify icon"}
- src={`/images/icons/star1.svg`}
+ src={`/images/icons/star1.png`}
className="pb-1"
/>
@@ -195,14 +195,14 @@ function TicketChat({ params }: ITicketChatProps) {
}`}
>
{item.name === "normal"
- ? "درخواست همکاری"
+ ? t("offerPage.types.normal")
: item.name === "free"
- ? "ثبت درخواست رایگان"
+ ? t("offerPage.types.free")
: item.name === "special"
- ? "نمایش با آیکون ویژه"
- : "نمایش با رنگ پس زمینه متفاوت"}
+ ? t("offerPage.types.special")
+ : t("offerPage.types.highlight")}
{item.price !== 0
- ? ": " + Number(item.price).toLocaleString() + "تومان"
+ ? ": " + Number(item.price).toLocaleString() + t("offerPage.currencySuffix")
: ""}
@@ -210,7 +210,7 @@ function TicketChat({ params }: ITicketChatProps) {
})}
- ثبت درخواست
+ {t("offerPage.submitRequest")}
diff --git a/src/app/offer/payment/failed/page.tsx b/src/app/offer/payment/failed/page.tsx
index 8c9d30e..082473d 100644
--- a/src/app/offer/payment/failed/page.tsx
+++ b/src/app/offer/payment/failed/page.tsx
@@ -6,32 +6,34 @@ import RoundedButton from "@/components/elements/RoundedButton";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
+import { useTranslation } from "react-i18next";
function FailedBillboard() {
+ const { t } = useTranslation("common");
const searchParams = useSearchParams();
const userId = searchParams.get("userId");
return (
- پرداخت ناموفق
+ {t("offerPage.paymentFailed.title")}
- پرداخت شما با خطا مواجه شد.
+ {t("offerPage.paymentFailed.message")}
- پرداخت مجدد
+ {t("offerPage.paymentFailed.retry")}
diff --git a/src/app/offer/payment/success/page.tsx b/src/app/offer/payment/success/page.tsx
index ce3c460..0c5f526 100644
--- a/src/app/offer/payment/success/page.tsx
+++ b/src/app/offer/payment/success/page.tsx
@@ -8,8 +8,10 @@ import RoundedButton from "@/components/elements/RoundedButton";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
+import { useTranslation } from "react-i18next";
function SuccessBillboard() {
+ const { t } = useTranslation("common");
const { request } = useAxios();
const searchParams = useSearchParams();
const userId = searchParams.get("userId");
@@ -29,13 +31,13 @@ function SuccessBillboard() {
return (
- پرداخت موفق
+ {t("offerPage.paymentSuccess.title")}
@@ -43,17 +45,19 @@ function SuccessBillboard() {
- یک درخواست همکاری برای کاربر {userDetail?.user_name} ثبت شد
+ {t("offerPage.paymentSuccess.message", {
+ username: userDetail?.user_name ?? "",
+ })}
- ارسال پیام
+ {t("offerPage.paymentSuccess.sendMessage")}
- بعدا
+ {t("offerPage.paymentSuccess.later")}
diff --git a/src/app/posts/[id]/[[...slug]]/page.tsx b/src/app/posts/[id]/[[...slug]]/page.tsx
index 6eb3de1..770ebf0 100644
--- a/src/app/posts/[id]/[[...slug]]/page.tsx
+++ b/src/app/posts/[id]/[[...slug]]/page.tsx
@@ -9,6 +9,9 @@ import {
buildPostSeoDescription,
buildPostSeoTitle,
} from "@/lib/postSlug";
+import { getSiteSeoMeta } from "@/lib/i18n/seo";
+import { getServerLanguage } from "@/lib/i18n/server";
+import { translateCommon } from "@/lib/i18n/translate";
type Props = {
params: Promise<{ id: string; slug?: string[] }>;
@@ -47,11 +50,14 @@ function toFeedSearchParams(
export async function generateMetadata({ params }: Props): Promise
{
const { id } = await params;
+ const lang = await getServerLanguage();
const post = await fetchPostById(id);
- const title = post ? buildPostSeoTitle(post) : "پست | مدستاگرام";
+ const title = post
+ ? buildPostSeoTitle(post, lang)
+ : translateCommon(lang, "postsMeta.defaultTitle");
const description = post
- ? buildPostSeoDescription(post)
- : "مشاهده پست در مدستاگرام — پلتفرم تخصصی حوزه زیبایی، مدلینگ و عکاسی";
+ ? buildPostSeoDescription(post, lang)
+ : translateCommon(lang, "postsMeta.defaultDescription");
const ogImage = post?.files?.[0]?.path
? buildStorageUrl(post.files[0].path)
: undefined;
@@ -60,23 +66,26 @@ export async function generateMetadata({ params }: Props): Promise {
([post.first_name, post.last_name].filter(Boolean).join(" ") ||
post.user_name);
+ const site = getSiteSeoMeta(lang);
+
return generatePageMetadata({
title,
description,
path: buildPostPath(id, post ?? undefined),
type: "article",
imageUrl: ogImage,
- imageAlt: post?.caption?.slice(0, 80) || "پست مدستاگرام",
+ imageAlt: post?.caption?.slice(0, 80) || translateCommon(lang, "postsMeta.defaultAlt"),
keywords: [
- "مدستاگرام",
+ site.name,
post?.expertise || "",
authorName || "",
- "پست",
- "مدلینگ",
- "زیبایی",
+ translateCommon(lang, "postsMeta.keywordPost"),
+ translateCommon(lang, "postsMeta.keywordModeling"),
+ translateCommon(lang, "postsMeta.keywordBeauty"),
].filter(Boolean),
publishedTime: post?.createdAt,
modifiedTime: post?.updatedAt,
+ lang,
});
}
diff --git a/src/app/robots.ts b/src/app/robots.ts
index 1dc5ea2..cb285f3 100644
--- a/src/app/robots.ts
+++ b/src/app/robots.ts
@@ -4,15 +4,17 @@ export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
- // اجازه دسترسی به صفحات اصلی و آرشیوها
allow: [
- '/',
- '/videos',
- '/billboards',
- '/users',
- '/posts'
+ '/',
+ '/videos',
+ '/billboards',
+ '/users/',
+ '/posts',
+ '/projects',
+ '/academy',
+ '/explore',
+ '/about-us',
],
- // جلوگیری از ایندکس صفحات سیستمی و شخصی
disallow: [
'/api/',
'/_next/',
@@ -22,14 +24,13 @@ export default function robots(): MetadataRoute.Robots {
'/new-post',
'/new-project',
'/search',
- '/*/payment/', // صفحات موفقیت یا شکست پرداخت
+ '/*/payment/',
'/verify/',
'/register',
'/login',
- '/*?*', // جلوگیری از ایندکس شدن لینکهای دارای فیلتر و کوئری استرینگ (جلوگیری از محتوای تکراری)
+ '/*?*',
],
},
- // معرفی نقشه سایت به گوگل
sitemap: 'https://modstagram.com/sitemap.xml',
}
-}
\ No newline at end of file
+}
diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx
index 6d11078..749e2e6 100644
--- a/src/app/search/page.tsx
+++ b/src/app/search/page.tsx
@@ -4,8 +4,13 @@ import SearchFilter from "@/components/search/SearchFilter";
import { fetchSearch } from "@/api/fetchSearch";
import InfiniteSearch from "@/components/search/InfiniteSearch";
import { Metadata } from "next";
-import { pageSeo } from "@/config/pageSeo";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import {
+ getListSeoTemplates,
+ getSeoPage,
+ interpolateTemplate,
+} from "@/lib/i18n/seo";
+import { generateSeoPageMetadata, getServerLanguage } from "@/lib/i18n/server";
interface ISearchProps {
searchParams: Promise<{
@@ -14,26 +19,25 @@ interface ISearchProps {
}>;
}
-// تبدیل متادیتای ثابت به داینامیک
export async function generateMetadata({ searchParams }: ISearchProps): Promise {
+ const lang = await getServerLanguage();
const { search } = await searchParams;
if (!search) {
- return generatePageMetadata({
- title: pageSeo.search.title,
- description: pageSeo.search.description,
- path: pageSeo.search.path,
- keywords: [...pageSeo.search.keywords],
- });
+ return generateSeoPageMetadata("search", { lang });
}
- const title = `نتایج جستجو برای «${search}» | مدستاگرام`;
- const description = `نتایج جستجوی تخصصی برای «${search}» در حوزه مد، زیبایی و مدلینگ در مدستاگرام.`;
+ const templates = getListSeoTemplates(lang).search as Record;
+ const searchPage = getSeoPage("search", lang);
return generatePageMetadata({
- title,
- description,
- path: `${pageSeo.search.path}?search=${encodeURIComponent(search)}`,
+ title: interpolateTemplate(templates.resultsTitle, { query: search }),
+ description: interpolateTemplate(templates.resultsDescription, {
+ query: search,
+ }),
+ path: `${searchPage.path}?search=${encodeURIComponent(search)}`,
+ keywords: searchPage.keywords,
+ lang,
});
}
@@ -44,23 +48,12 @@ export default async function Search({ searchParams }: ISearchProps) {
const initialData =
filters.search && filters.type
? await fetchSearch(1, 10, filters, token)
- : undefined;
+ : null;
return (
-
- {/* اضافه کردن H1 مخفی برای بهبود سئو */}
-
- {filters.search
- ? `نتایج جستجوی مدستاگرام برای: ${filters.search}`
- : "جستجوی تخصصی در پلتفرم مد و زیبایی مدستاگرام"}
-
-
+
-
+
);
-}
\ No newline at end of file
+}
diff --git a/src/app/settings/academy/my-courses/page.tsx b/src/app/settings/academy/my-courses/page.tsx
index dded9d6..848f863 100644
--- a/src/app/settings/academy/my-courses/page.tsx
+++ b/src/app/settings/academy/my-courses/page.tsx
@@ -1,14 +1,16 @@
"use client";
import { AcademyListSkeleton } from "@/components/academy/AcademySkeletons";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
-import React, { useEffect, useState, useCallback } from "react";
+import React, { useEffect, useState } from "react";
import MainModelCard from "@/components/academy/MainModelCard";
import { Course } from "@/types/types";
import Image from "next/image";
import toast from "react-hot-toast";
import { motion, AnimatePresence } from "framer-motion";
+import { useTranslation } from "react-i18next";
interface PaginationData {
page: number;
@@ -21,6 +23,7 @@ interface PaginationData {
export default function PurchasedCoursesPage() {
const { request } = useAxios();
+ const { t } = useTranslation("common");
const [courses, setCourses] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [pagination, setPagination] = useState({
@@ -40,12 +43,12 @@ export default function PurchasedCoursesPage() {
"GET",
`/academy/course/getUserPurchasedCoursesWithPopulate?page=${page}&limit=6`
);
-
+
console.log("Response:", response);
-
+
if (response?.success && response?.data?.courses) {
setCourses(response.data.courses);
-
+
if (response.data.pagination) {
setPagination({
page: response.data.pagination.page,
@@ -58,11 +61,11 @@ export default function PurchasedCoursesPage() {
}
} else {
setCourses([]);
- toast.error("خطا در دریافت اطلاعات");
+ toast.error(t("settings.academy.myCourses.fetchError"));
}
} catch (err) {
console.log("get error:", err);
- toast.error("خطا در دریافت دورههای خریداری شده");
+ toast.error(t("settings.academy.myCourses.fetchPurchasedError"));
setCourses([]);
} finally {
setIsLoading(false);
@@ -82,237 +85,236 @@ export default function PurchasedCoursesPage() {
if (isLoading) {
return (
-
+
+
+
);
}
return (
-
- {/* هدر صفحه */}
-
-
-
-
-
- دورههای خریداری شده من
-
-
- به جمعآوری دورههای خود نگاهی بیندازید و یادگیری را ادامه دهید
-
-
-
-
-
-
- {pagination.total} دوره خریداری شده
-
-
-
-
- {/* منحنی پایین هدر */}
-
-
-
- {/* محتوای اصلی */}
-
- {courses.length === 0 ? (
-
-
- {
- (e.target as HTMLImageElement).src = "/images/empty-box.png";
- }}
- />
-
-
- هنوز دورهای خریداری نکردهاید
-
-
- اولین دوره خود را خریداری کنید و مسیر یادگیری را شروع کنید
-
-
-
-
-
- مشاهده دورهها
-
-
- ) : (
- <>
- {/* آمار دورهها */}
+
+
+
+
+
-
-
-
-
- تعداد کل دورهها
-
-
- {pagination.total}
-
-
-
-
-
-
-
-
-
-
- صفحه جاری
-
-
- {pagination.page} / {pagination.totalPages}
-
-
-
-
-
-
-
-
-
-
- نمایش در هر صفحه
-
-
- {pagination.limit}
-
-
-
-
+
+ {t("settings.academy.myCourses.title")}
+
+
+ {t("settings.academy.myCourses.subtitle")}
+
+
+
+
+
+
+ {t("settings.academy.myCourses.purchasedCount", {
+ count: pagination.total,
+ })}
+
+
+
+
- {/* لیست دورهها */}
-
-
+ {courses.length === 0 ? (
+
+
+ {
+ (e.target as HTMLImageElement).src = "/images/empty-box.png";
+ }}
+ />
+
+
+ {t("settings.academy.myCourses.emptyTitle")}
+
+
+ {t("settings.academy.myCourses.emptyDescription")}
+
+
- {courses.map((course, index) => (
-
-
-
- ))}
-
-
-
- {/* پیجینیشن */}
- {pagination.totalPages > 1 && (
+
+
+
+ {t("settings.academy.myCourses.browseCourses")}
+
+
+ ) : (
+ <>
-
-
handlePageChange(currentPage - 1)}
- disabled={!pagination.hasPrevPage}
- className={`
+
+
+
+
+ {t("settings.academy.myCourses.totalCourses")}
+
+
+ {pagination.total}
+
+
+
+
+
+
+
+
+
+
+ {t("settings.academy.myCourses.currentPage")}
+
+
+ {pagination.page} / {pagination.totalPages}
+
+
+
+
+
+
+
+
+
+
+ {t("settings.academy.myCourses.perPage")}
+
+
+ {pagination.limit}
+
+
+
+
+
+
+
+
+
+ {courses.map((course, index) => (
+
+
+
+ ))}
+
+
+
+ {pagination.totalPages > 1 && (
+
+
+
handlePageChange(currentPage - 1)}
+ disabled={!pagination.hasPrevPage}
+ className={`
px-4 py-2 rounded-lg transition-all duration-200
${
pagination.hasPrevPage
@@ -320,26 +322,27 @@ export default function PurchasedCoursesPage() {
: "opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-600"
}
`}
- >
-
-
-
-
+
+
+
+
-
- {Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map(
- (page) => {
- // نمایش حداکثر 5 صفحه
+
+ {Array.from(
+ { length: pagination.totalPages },
+ (_, i) => i + 1
+ ).map((page) => {
if (
page === 1 ||
page === pagination.totalPages ||
@@ -362,7 +365,6 @@ export default function PurchasedCoursesPage() {
);
}
- // نمایش نقطه چین
if (
page === currentPage - 2 ||
page === currentPage + 2
@@ -377,14 +379,13 @@ export default function PurchasedCoursesPage() {
);
}
return null;
- }
- )}
-
+ })}
+
-
handlePageChange(currentPage + 1)}
- disabled={!pagination.hasNextPage}
- className={`
+ handlePageChange(currentPage + 1)}
+ disabled={!pagination.hasNextPage}
+ className={`
px-4 py-2 rounded-lg transition-all duration-200
${
pagination.hasNextPage
@@ -392,49 +393,52 @@ export default function PurchasedCoursesPage() {
: "opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-600"
}
`}
- >
-
-
-
-
-
-
- )}
+
+
+
+
+
+
+ )}
- {/* دکمه بازگشت به بالا */}
- {courses.length > 3 && (
-
window.scrollTo({ top: 0, behavior: "smooth" })}
- className="fixed bottom-8 right-8 bg-gradient-to-r from-blue-500 to-purple-600 text-white p-3 rounded-full shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-110 z-50"
- >
- 3 && (
+
+ window.scrollTo({ top: 0, behavior: "smooth" })
+ }
+ className="fixed bottom-8 right-8 bg-gradient-to-r from-blue-500 to-purple-600 text-white p-3 rounded-full shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-110 z-50"
+ aria-label={t("backToTop.aria")}
>
-
-
-
- )}
- >
- )}
+
+
+
+
+ )}
+ >
+ )}
+
-
+
);
-}
\ No newline at end of file
+}
diff --git a/src/app/settings/academy/storeProfile/page.tsx b/src/app/settings/academy/storeProfile/page.tsx
index e7d385d..d8c3bd4 100644
--- a/src/app/settings/academy/storeProfile/page.tsx
+++ b/src/app/settings/academy/storeProfile/page.tsx
@@ -21,7 +21,6 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/Textarea";
-import { ScrollArea } from "@radix-ui/react-scroll-area";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import "leaflet/dist/leaflet.css";
@@ -31,22 +30,46 @@ import { Academy } from "@/types/types";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import { AcademyProfileHeadSkeleton } from "@/components/academy/AcademySkeletons";
import { Skeleton } from "@/components/ui/skeleton";
+import {
+ getIranBankName,
+ isValidIranSheba,
+ stripShebaPrefix,
+} from "@/lib/iranSheba";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { useMemo } from "react";
+import { cn } from "@/lib/utils";
+import { btnPrimary } from "@/lib/ui/buttonStyles";
-// Zod schema for store settings
-const storeSchema = z.object({
- name: z.string(),
- sheba: z.string(),
- bio: z.string().optional(),
- profileImage: z.string().optional(),
- tags: z.array(z.object({ value: z.string() })).optional(),
-});
+function buildStoreSchema(t: (key: string) => string) {
+ return z.object({
+ name: z.string(),
+ sheba: z
+ .string()
+ .regex(/^\d{24}$/, t("settings.academy.storeProfile.shebaInvalid")),
+ bio: z.string().optional(),
+ profileImage: z.string().optional(),
+ tags: z.array(z.object({ value: z.string() })).optional(),
+ contact_mobile: z.string().optional(),
+ contact_telegram: z.string().optional(),
+ contact_whatsapp: z.string().optional(),
+ contact_instagram: z.string().optional(),
+ });
+}
-type StoreFormValues = z.infer
;
+type StoreFormValues = z.infer>;
interface AcademyResponse {
- academy: Academy;
+ academy: Academy & {
+ contactInfo?: {
+ mobile?: string;
+ phone?: string;
+ telegramLink?: string;
+ whatsappNumber?: string;
+ instagramLink?: string;
+ };
+ };
}
//
@@ -56,6 +79,8 @@ export default function StoreSettings() {
const { request, loading, error } = useAxios();
const router = useRouter();
const { t } = useTranslation("common");
+ const storeSchema = useMemo(() => buildStoreSchema(t), [t]);
+ const tagPlaceholder = t("settings.academy.storeProfile.tagPlaceholder");
const [previewProfile, setPreviewProfile] = useState("");
const [avatar, setAvatar] = useState(null);
const [isLoading, setIsLoading] = useState(false);
@@ -72,9 +97,17 @@ export default function StoreSettings() {
profileImage: "",
sheba: "",
tags: [],
+ contact_mobile: "",
+ contact_telegram: "",
+ contact_whatsapp: "",
+ contact_instagram: "",
},
});
+ const shebaValue = form.watch("sheba");
+ const shebaValid = isValidIranSheba(shebaValue || "");
+ const bankName = shebaValid ? getIranBankName(shebaValue || "") : null;
+
const parseTags = (jsonString: string): TagType[] => {
try {
const parsed = JSON.parse(jsonString);
@@ -101,14 +134,19 @@ export default function StoreSettings() {
if (response?.academy) {
const tagsArray = parseTags(response.academy.tag || "");
+ const contact = response.academy.contactInfo;
// تنظیم مقادیر فرم
form.reset({
name: response.academy.academy_name || "",
bio: response.academy.bio || "",
profileImage: response.academy.academy_image || "",
- sheba: response.academy.sheba || "",
+ sheba: stripShebaPrefix(response.academy.sheba || ""),
tags: tagsArray,
+ contact_mobile: contact?.mobile || contact?.phone || "",
+ contact_telegram: contact?.telegramLink || "",
+ contact_whatsapp: contact?.whatsappNumber || "",
+ contact_instagram: contact?.instagramLink || "",
});
// تنظیم پیشنمایش عکس
@@ -119,7 +157,7 @@ export default function StoreSettings() {
}
} catch (error) {
console.error("خطا در دریافت اطلاعات:", error);
- toast.error("خطا در دریافت اطلاعات");
+ toast.error(t("settings.academy.storeProfile.fetchError"));
} finally {
setIsFetching(false);
}
@@ -150,24 +188,24 @@ export default function StoreSettings() {
setIsLoading(true);
if (!data.name) {
- toast.error("نام را پر کنید");
+ toast.error(t("settings.academy.storeProfile.nameRequired"));
setIsLoading(false);
return;
}
- if (!data.sheba) {
- toast.error("شبا را پر کنید");
+ if (!data.sheba || !isValidIranSheba(data.sheba)) {
+ toast.error(t("settings.academy.storeProfile.shebaRequired"));
setIsLoading(false);
return;
}
if (!data.bio) {
- toast.error("بیو را پر کنید");
+ toast.error(t("settings.academy.storeProfile.bioRequired"));
setIsLoading(false);
return;
}
const fileInput = document.getElementById("fileInput") as HTMLInputElement;
if (!fileInput?.files?.[0]) {
- toast.error("لطفا تصویر پروفایل را انتخاب کنید");
+ toast.error(t("settings.academy.storeProfile.imageRequired"));
setIsLoading(false);
return;
}
@@ -179,6 +217,10 @@ export default function StoreSettings() {
formData.append("sheba", data.sheba);
formData.append("bio", data.bio);
formData.append("tag", JSON.stringify(data.tags || []));
+ formData.append("contact_mobile", data.contact_mobile || "");
+ formData.append("contact_telegram", data.contact_telegram || "");
+ formData.append("contact_whatsapp", data.contact_whatsapp || "");
+ formData.append("contact_instagram", data.contact_instagram || "");
try {
await request("POST", "/academy/academy/profile", formData, {
@@ -186,11 +228,11 @@ export default function StoreSettings() {
"Content-Type": "multipart/form-data",
},
});
- toast.success("ثبت شد");
+ toast.success(t("settings.academy.storeProfile.saved"));
router.push("/settings/academy/Dashboard");
} catch (err) {
console.log("Upload error:", err);
- toast.error("خطا در ثبت اطلاعات");
+ toast.error(t("settings.academy.storeProfile.saveError"));
} finally {
setIsLoading(false);
}
@@ -211,15 +253,16 @@ export default function StoreSettings() {
}
return (
+
- {"مشخصات آموزشگاه"}
+ {t("settings.academy.storeProfile.title")}
-
+
(
- {"نام آموزشگاه"}
+ {t("settings.academy.storeProfile.name")}
-
+
@@ -250,10 +290,40 @@ export default function StoreSettings() {
name="sheba"
render={({ field }) => (
- {"شبا"}
-
-
-
+ {t("settings.academy.storeProfile.sheba")}
+
+
+
+
+
+ IR
+
+ {
+ const digits = e.target.value
+ .replace(/\D/g, "")
+ .slice(0, 24);
+ field.onChange(digits);
+ }}
+ />
+
+
+ {bankName ? (
+
+ {bankName}
+
+ ) : null}
+
+
)}
@@ -265,10 +335,10 @@ export default function StoreSettings() {
name="bio"
render={({ field }) => (
- {"بیو"}
+ {t("settings.academy.storeProfile.bio")}
@@ -276,9 +346,65 @@ export default function StoreSettings() {
)}
/>
+
+
+
+
- {"عکس پروفایل"}
+ {t("settings.academy.storeProfile.profileImage")}
- {"تگ های فروشگاه"}
+ {t("settings.academy.storeProfile.storeTags")}
{tagFields.map((tag, index) => (
@@ -313,7 +439,7 @@ export default function StoreSettings() {
{
if (e.key === "Enter" && e.currentTarget.value) {
appendTag({ value: e.currentTarget.value });
@@ -325,7 +451,7 @@ export default function StoreSettings() {
type="button"
onClick={() => {
const input = document.querySelector(
- 'input[placeholder="تگ"]'
+ `input[placeholder="${tagPlaceholder}"]`
) as HTMLInputElement;
if (input.value) {
appendTag({ value: input.value });
@@ -333,22 +459,18 @@ export default function StoreSettings() {
}
}}
>
- {"ثبت"}
+ {t("settings.academy.storeProfile.submit")}
-
+
{isLoading ? (
<>
@@ -371,16 +493,17 @@ export default function StoreSettings() {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
- "در حال ثبت..."
+ {t("settings.academy.storeProfile.submitting")}
>
) : (
- "ثبت"
+ t("settings.academy.storeProfile.submit")
)}
-
-
+
+
+
);
}
diff --git a/src/app/settings/academy/wallet/page.tsx b/src/app/settings/academy/wallet/page.tsx
index 12dab6d..00d8517 100644
--- a/src/app/settings/academy/wallet/page.tsx
+++ b/src/app/settings/academy/wallet/page.tsx
@@ -34,6 +34,8 @@ import { motion } from "framer-motion";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import useAxios from "@/hooks/useAxios";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { useAppLanguage } from "@/contexts/LanguageProvider";
interface Payment {
_id: string;
@@ -77,6 +79,8 @@ const WalletDashboard = () => {
const { request } = useAxios();
const { t } = useTranslation("common");
+ const { language } = useAppLanguage();
+ const dateLocale = language === "fa" ? "fa-IR" : "en-US";
// دریافت پرداختهای موفق (فروشهای انجام شده)
const fetchSuccessfulPayments = async () => {
@@ -99,9 +103,9 @@ const WalletDashboard = () => {
const salesTransactions: Transaction[] = payments.map((payment) => ({
id: payment._id,
type: "expense",
- currency: "تومان",
+ currency: t("settings.toman"),
amount: payment.taxAmount,
- date: new Date(payment.createdAt).toLocaleDateString("fa-IR"),
+ date: new Date(payment.createdAt).toLocaleDateString(dateLocale),
description: payment.course_name,
status: "pending",
refId: payment.payment_ref_id,
@@ -112,7 +116,7 @@ const WalletDashboard = () => {
return [];
} catch (err) {
console.log("error fetching successful payments:", err);
- toast.error("خطا در دریافت فروشها");
+ toast.error(t("settings.academy.wallet.fetchSalesError"));
return [];
}
};
@@ -138,10 +142,12 @@ const WalletDashboard = () => {
const settledTransactions: Transaction[] = payments.map((payment) => ({
id: payment._id,
type: "withdraw",
- currency: "تومان",
+ currency: t("settings.toman"),
amount: payment.taxAmount,
- date: new Date(payment.updatedAt).toLocaleDateString("fa-IR"),
- description: `تسویه حساب - ${payment.course_name}`,
+ date: new Date(payment.updatedAt).toLocaleDateString(dateLocale),
+ description: t("settings.academy.wallet.settlementDescription", {
+ course: payment.course_name,
+ }),
status: "settled",
refId: payment.payment_ref_id,
}));
@@ -151,7 +157,7 @@ const WalletDashboard = () => {
return [];
} catch (err) {
console.log("error fetching settled payments:", err);
- toast.error("خطا در دریافت تسویهها");
+ toast.error(t("settings.academy.wallet.fetchSettledError"));
return [];
}
};
@@ -188,7 +194,7 @@ const WalletDashboard = () => {
} catch (err) {
console.log("error fetching all data:", err);
- toast.error("خطا در دریافت اطلاعات");
+ toast.error(t("settings.academy.wallet.fetchError"));
} finally {
setIsLoading(false);
}
@@ -224,6 +230,7 @@ const WalletDashboard = () => {
}
return (
+
@@ -239,14 +246,14 @@ const WalletDashboard = () => {
>
- کل فروش
+ {t("settings.academy.wallet.totalSales")}
- مجموع فروشهای موفق
+ {t("settings.academy.wallet.totalSalesDesc")}
- {(stats.sales + stats.settled).toLocaleString()} تومان
+ {(stats.sales + stats.settled).toLocaleString(dateLocale)} {t("settings.toman")}
@@ -260,14 +267,14 @@ const WalletDashboard = () => {
>
- تسویه شده
+ {t("settings.academy.wallet.settled")}
- مبالغ پرداخت شده به حساب
+ {t("settings.academy.wallet.settledDesc")}
- {stats.settled.toLocaleString()} تومان
+ {stats.settled.toLocaleString(dateLocale)} {t("settings.toman")}
@@ -277,7 +284,7 @@ const WalletDashboard = () => {
{/* جداول تراکنشها */}
- گزارش تراکنشها
+ {t("settings.academy.wallet.transactionsReport")}
{
}
>
-
+
- همه
- فروش
- پرداخت شده
+ {t("settings.academy.wallet.all")}
+ {t("settings.academy.wallet.sales")}
+ {t("settings.academy.wallet.paidOut")}
@@ -299,7 +306,7 @@ const WalletDashboard = () => {
{filteredTransactions.length === 0 ? (
- هیچ تراکنشی یافت نشد
+ {t("settings.academy.wallet.noTransactions")}
) : (
@@ -308,10 +315,10 @@ const WalletDashboard = () => {
- شرح
- مبلغ
- تاریخ
- وضعیت
+ {t("settings.academy.wallet.description")}
+ {t("settings.academy.wallet.amount")}
+ {t("settings.academy.wallet.date")}
+ {t("settings.status")}
@@ -319,7 +326,7 @@ const WalletDashboard = () => {
{filteredTransactions.length === 0 ? (
- هیچ تراکنشی یافت نشد
+ {t("settings.academy.wallet.noTransactions")}
) : (
@@ -350,7 +357,9 @@ const WalletDashboard = () => {
: "bg-green-500/10 text-green-600 border-green-500/30"
}`}
>
- {transaction.type === "expense" ? "در انتظار تسویه" : "تسویه شده"}
+ {transaction.type === "expense"
+ ? t("settings.academy.wallet.pendingSettlement")
+ : t("settings.academy.wallet.settlementDone")}
@@ -365,7 +374,7 @@ const WalletDashboard = () => {
{filteredTransactions.length === 0 ? (
- هیچ تراکنشی یافت نشد
+ {t("settings.academy.wallet.noTransactions")}
) : (
filteredTransactions.map((transaction) => (
@@ -396,7 +405,9 @@ const WalletDashboard = () => {
: "bg-green-500/10 text-green-600"
}`}
>
- {transaction.type === "expense" ? "در انتظار تسویه" : "تسویه شده"}
+ {transaction.type === "expense"
+ ? t("settings.academy.wallet.pendingSettlement")
+ : t("settings.academy.wallet.settlementDone")}
@@ -412,6 +423,7 @@ const WalletDashboard = () => {
+
);
};
diff --git a/src/app/settings/chats/[username]/[id]/page.tsx b/src/app/settings/chats/[username]/[id]/page.tsx
index 9f7eac3..139fec0 100644
--- a/src/app/settings/chats/[username]/[id]/page.tsx
+++ b/src/app/settings/chats/[username]/[id]/page.tsx
@@ -28,9 +28,15 @@ import { normalizeThreadId, upsertMessageInThreadCache } from "@/lib/chat/thread
import { useStableMessageKeys } from "@/hooks/useStableMessageKeys";
import { getStoredUserId } from "@/lib/auth/session";
import { isViewOnceMediaType } from "@/lib/chat/viewOnce";
+import {
+ copyTextToClipboard,
+ getCopyableMessageText,
+} from "@/lib/chat/getCopyableMessageText";
import {
findPendingMatchForServer,
} from "@/lib/chat/dedupeMessages";
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
interface ITicketChatProps {
params: Promise<{ id: string; username: string }>;
@@ -45,6 +51,7 @@ function normalizeMessage(raw: ChatMessage & { data?: ChatMessage }): ChatMessag
}
function TicketChat({ params }: ITicketChatProps) {
+ const { t } = useTranslation("common");
const resolvedParams = React.use(params);
const { request } = useAxios();
const { username, id: chatPartnerId } = resolvedParams;
@@ -76,23 +83,22 @@ function TicketChat({ params }: ITicketChatProps) {
const [isTyping, setIsTyping] = useState(false);
const typingTimeoutRef = useRef(null);
const activeReplyRef = useRef(null);
+ const scrollToMessageRef = useRef<(targetId: string) => void>(() => {});
const handleIncomingMessage = useCallback(
(msg: ChatMessage) => {
setPendingMessages((prev) => {
const match = findPendingMatchForServer(msg, prev);
- if (match) linkIds(match._id, msg._id);
+ if (match) {
+ linkIds(match._id, msg._id);
+ return prev.filter((m) => m._id !== match._id);
+ }
return prev;
});
},
[linkIds]
);
- const pruneResolvedPending = useCallback((ids: string[]) => {
- if (!ids.length) return;
- setPendingMessages((prev) => prev.filter((m) => !ids.includes(m._id)));
- }, []);
-
const confirmSentMessage = useCallback(
(tempId: string, serverMsg: ChatMessage, replyPayload?: ChatMessage["replyTo"]) => {
const sentMsg: ChatMessage = {
@@ -114,7 +120,8 @@ function TicketChat({ params }: ITicketChatProps) {
(oldData) => upsertMessageInThreadCache(oldData, sentMsg)
);
}
- // pending را اینجا حذف نکن — ChatMessageList بعد از sync شدن cache prune میکند
+
+ setPendingMessages((prev) => prev.filter((m) => m._id !== tempId));
},
[user?._id, userTwoDetail?._id, receiverId, chatPartnerId, linkIds, queryClient]
);
@@ -160,7 +167,7 @@ function TicketChat({ params }: ITicketChatProps) {
const MAX_SIZE = 200 * 1024 * 1024;
const valid = picked.filter((f) => {
if (f.size > MAX_SIZE) {
- toast.error(`${f.name}: حجم بیش از ۲۰۰ مگابایت`);
+ toast.error(t("chats.toast.fileTooLargeNamed", { name: f.name }));
return false;
}
return true;
@@ -198,7 +205,7 @@ function TicketChat({ params }: ITicketChatProps) {
const handleLocationShare = () => {
if (!navigator.geolocation) {
- toast.error("مرورگر از موقعیت مکانی پشتیبانی نمیکند.");
+ toast.error(t("chats.toast.geolocationUnsupported"));
return;
}
navigator.geolocation.getCurrentPosition(
@@ -206,11 +213,11 @@ function TicketChat({ params }: ITicketChatProps) {
const locationContent = JSON.stringify({
lat: pos.coords.latitude,
lng: pos.coords.longitude,
- label: "موقعیت من",
+ label: t("chats.locationLabel"),
});
processSendMessage(null, "location", locationContent);
},
- () => toast.error("دسترسی به موقعیت مکانی داده نشد.")
+ () => toast.error(t("chats.toast.geolocationDenied"))
);
};
@@ -235,10 +242,10 @@ function TicketChat({ params }: ITicketChatProps) {
if (!source) return undefined;
return {
_id: source._id,
- content: source.content?.slice(0, 120) || "پیام",
+ content: source.content?.slice(0, 120) || t("chats.message"),
senderName:
source.senderId === user?._id
- ? "شما"
+ ? t("chats.you")
: `${userTwoDetail?.first_name || ""} ${userTwoDetail?.last_name || ""}`.trim(),
};
};
@@ -253,7 +260,7 @@ function TicketChat({ params }: ITicketChatProps) {
if (textContent.trim() === "" && !fileToUpload) return;
if (userTwoDetail?.blocked_you || userTwoDetail?.is_blocked) {
- toast.error("امکان ارسال پیام وجود ندارد.");
+ toast.error(t("chats.toast.sendBlocked"));
return;
}
@@ -288,7 +295,7 @@ function TicketChat({ params }: ITicketChatProps) {
let fileForUpload = fileToUpload;
if (fileToUpload && fileType !== "location") {
try {
- toast.loading("در حال بهینهسازی فایل…", { id: "media-opt" });
+ toast.loading(t("chats.toast.optimizingFile"), { id: "media-opt" });
fileForUpload = await optimizeMediaFile(fileToUpload);
toast.dismiss("media-opt");
} catch {
@@ -341,10 +348,11 @@ function TicketChat({ params }: ITicketChatProps) {
const serverMsg = normalizeMessage(response as ChatMessage);
confirmSentMessage(tempId, serverMsg, replyPayload);
+ void queryClient.invalidateQueries({ queryKey: ["messages"] });
} catch (error) {
console.error("Error sending message:", error);
setPendingMessages((prev) => prev.filter((m) => m._id !== tempId));
- toast.error("ارسال پیام ناموفق بود.");
+ toast.error(t("chats.toast.sendFailedDot"));
return;
}
@@ -385,6 +393,110 @@ function TicketChat({ params }: ITicketChatProps) {
closeActionMode();
};
+ const copySelectedMessage = async () => {
+ if (!selectedMessage) return;
+ const text = getCopyableMessageText(selectedMessage);
+ if (!text) {
+ toast.error(t("chats.toast.copyEmpty"));
+ return;
+ }
+
+ const copied = await copyTextToClipboard(text);
+ if (copied) {
+ toast.success(t("chats.toast.copySuccess"));
+ closeActionMode();
+ return;
+ }
+
+ toast.error(t("chats.toast.copyFailed"));
+ };
+
+ const reactToMessage = async (emoji: string) => {
+ if (!selectedMessage?._id || selectedMessage._id.startsWith("temp-")) return;
+ const messageId = selectedMessage._id;
+ const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId());
+ const targetReceiverId = normalizeThreadId(
+ userTwoDetail?._id ?? receiverId ?? chatPartnerId
+ );
+
+ const toggleReaction = (
+ reactions: ChatMessage["reactions"] = []
+ ): ChatMessage["reactions"] => {
+ const list = [...(reactions ?? [])];
+ const idx = list.findIndex((r) => String(r.userId) === currentUserId);
+ if (idx >= 0 && list[idx].emoji === emoji) {
+ list.splice(idx, 1);
+ } else if (idx >= 0) {
+ list[idx] = { userId: currentUserId, emoji };
+ } else {
+ list.push({ userId: currentUserId, emoji });
+ }
+ return list;
+ };
+
+ const optimisticReactions = toggleReaction(selectedMessage.reactions);
+ if (currentUserId && targetReceiverId) {
+ queryClient.setQueryData(
+ chatThreadQueryKey(currentUserId, targetReceiverId),
+ (oldData) => {
+ if (!oldData) return oldData;
+ return {
+ ...oldData,
+ pages: oldData.pages.map((page) => ({
+ ...page,
+ messages: page.messages.map((m) =>
+ m._id === messageId
+ ? { ...m, reactions: optimisticReactions }
+ : m
+ ),
+ })),
+ };
+ }
+ );
+ }
+
+ try {
+ const response = await request<{ data?: ChatMessage }>(
+ "POST",
+ "/chat/reaction",
+ {
+ messageId,
+ emoji,
+ receiverId: targetReceiverId,
+ }
+ );
+ const updated = response?.data;
+ if (updated && currentUserId && targetReceiverId) {
+ queryClient.setQueryData(
+ chatThreadQueryKey(currentUserId, targetReceiverId),
+ (oldData) => upsertMessageInThreadCache(oldData, updated)
+ );
+ }
+ closeActionMode();
+ } catch {
+ if (currentUserId && targetReceiverId) {
+ queryClient.setQueryData(
+ chatThreadQueryKey(currentUserId, targetReceiverId),
+ (oldData) => {
+ if (!oldData) return oldData;
+ return {
+ ...oldData,
+ pages: oldData.pages.map((page) => ({
+ ...page,
+ messages: page.messages.map((m) =>
+ m._id === messageId
+ ? { ...m, reactions: selectedMessage.reactions }
+ : m
+ ),
+ })),
+ };
+ }
+ );
+ }
+ toast.error(t("chats.toast.reactFailed"));
+ }
+ };
+
const handleBlockChange = (blocked?: boolean) => {
if (typeof blocked === "boolean") {
setUserTwoDetail((prev) =>
@@ -446,16 +558,17 @@ function TicketChat({ params }: ITicketChatProps) {
};
}
);
- toast.success(response?.message || "پیامها حذف شدند");
+ toast.success(response?.message || t("chats.toast.messagesDeleted"));
cancelDeleteMode();
} catch {
- toast.error("حذف پیامها ناموفق بود");
+ toast.error(t("chats.toast.deleteMessagesFailed"));
} finally {
setDeleteLoading(false);
}
};
return (
+
{
+ scrollToMessageRef.current = fn;
+ }}
extraBottomRem={
(replyingTo ? 2.75 : 0) +
(selfDestructSeconds != null ? 2 : 0) +
@@ -506,7 +621,9 @@ function TicketChat({ params }: ITicketChatProps) {
key="actions"
onReply={startReply}
onForward={startForward}
+ onCopy={copySelectedMessage}
onCancel={closeActionMode}
+ onReact={reactToMessage}
/>
) : (
{
+ if (replyingTo?._id) scrollToMessageRef.current(replyingTo._id);
+ }}
replyLabel={
replyingTo
- ? replyingTo.content?.slice(0, 80) || "پیام"
+ ? replyingTo.content?.slice(0, 80) || t("chats.message")
: undefined
}
selfDestructSeconds={selfDestructSeconds}
@@ -584,6 +704,7 @@ function TicketChat({ params }: ITicketChatProps) {
/>
)}
+
);
}
diff --git a/src/app/settings/chats/layout.tsx b/src/app/settings/chats/layout.tsx
index 61e5145..e9a7f9d 100644
--- a/src/app/settings/chats/layout.tsx
+++ b/src/app/settings/chats/layout.tsx
@@ -1,12 +1,11 @@
import type { Metadata } from "next";
-import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
+import ChatSocketProvider from "@/providers/ChatSocketProvider";
-export const metadata: Metadata = generatePageMetadata({
- title: "پیامها | مدستاگرام",
- description: "گفتگوها و پیامهای خصوصی در مدستاگرام",
- path: "/settings/chats",
-});
+export async function generateMetadata(): Promise {
+ return generateSeoPageMetadata("settingsChats", { index: false });
+}
export default function Layout({ children }: { children: React.ReactNode }) {
- return children;
+ return {children} ;
}
diff --git a/src/app/settings/chats/page.tsx b/src/app/settings/chats/page.tsx
index ce6d712..76824e2 100644
--- a/src/app/settings/chats/page.tsx
+++ b/src/app/settings/chats/page.tsx
@@ -2,22 +2,25 @@
import Container from "@/components/elements/Container";
import RoundedInput from "@/components/elements/RoundedInput";
-import { buildStorageUrl } from "@/components/main/BaseUrl";
+import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import PageTitle from "@/components/settings/PageTitle";
import UserDetails from "@/components/settings/UserDetails";
+import StoryRing from "@/components/stories/StoryRing";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { useUser } from "@/hooks/useUser";
-import Image from "next/image";
import Link from "next/link";
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
import { ChatListSkeleton } from "@/components/ui/ChatSkeletons";
-import { useQueryClient } from "@tanstack/react-query";
-import {
- acquireChatSocket,
- joinUserRoom,
- releaseChatSocket,
-} from "@/lib/chat/socketClient";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { fetchStoriesFeed } from "@/api/fetchStories";
+import Cookies from "js-cookie";
+import { getChatSocket } from "@/lib/chat/socketClient";
+import BoldIcon from "@/components/ui/BoldIcon";
+import { cn } from "@/lib/utils";
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { isUserOnline, resolveOnlineLabel } from "@/lib/chat/onlineStatus";
export interface IMessage {
first_name: string;
@@ -35,37 +38,65 @@ export interface IMessage {
}
function Chats() {
+ const { t } = useTranslation("common");
const [searchText, setSearchText] = useState("");
const [search, setSearch] = useState("");
const user = useUser();
const queryClient = useQueryClient();
+ const token = Cookies.get("token") || "";
- const { data, isFetchingNextPage, isLoading } = useInfiniteScroll({
+ const { data, isFetchingNextPage, isLoading, refetch } = useInfiniteScroll({
endpoint: "/messages",
queryKey: ["messages", search],
params: { search: search },
});
useEffect(() => {
- if (!user?._id) return;
- const socket = acquireChatSocket();
- const uid = String(user._id);
+ const id = window.setInterval(() => {
+ void refetch();
+ }, 30_000);
+ return () => window.clearInterval(id);
+ }, [refetch]);
- const onConnect = () => joinUserRoom(uid);
- socket.on("connect", onConnect);
- if (socket.connected) onConnect();
+ const { data: storiesData } = useQuery({
+ queryKey: ["stories-feed", token],
+ queryFn: () => fetchStoriesFeed(token),
+ enabled: Boolean(token),
+ staleTime: 60_000,
+ });
+
+ const storyByUserId = useMemo(() => {
+ const map = new Map();
+ const add = (key: string | undefined, meta: { hasStory: boolean; hasUnviewed: boolean }) => {
+ if (!key) return;
+ map.set(String(key), meta);
+ };
+ for (const item of storiesData?.feed ?? []) {
+ if (!item.stories.length) continue;
+ const meta = {
+ hasStory: true,
+ hasUnviewed: item.has_unviewed,
+ };
+ add(item.user._id, meta);
+ add(item.user.user_name, meta);
+ }
+ return map;
+ }, [storiesData]);
+
+ useEffect(() => {
+ const socket = getChatSocket();
+ if (!socket || !user?._id) return;
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ["messages"] });
};
+
socket.on("chatListUpdate", invalidate);
socket.on("newMessage", invalidate);
return () => {
- socket.off("connect", onConnect);
socket.off("chatListUpdate", invalidate);
socket.off("newMessage", invalidate);
- releaseChatSocket();
};
}, [user?._id, queryClient]);
@@ -74,71 +105,136 @@ function Chats() {
(data?.pages.length === 0 ||
(data?.pages[0]?.filteredUsersData?.length === 0 && !isFetchingNextPage));
+ const onlineLabel = t("chats.online");
+ const offlineLabel = t("chats.offline");
+
return (
-
- پیام ها
-
-
-
- setSearchText(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") setSearch(searchText);
- }}
- placeholder="جستجو..."
- />
-
- {isLoading ? (
-
- ) : isEmpty ? (
-
- هنوز مکالمهای ندارید.
-
- ) : (
-
- {data?.pages.map((page) =>
- page.filteredUsersData?.map((item: IMessage) => (
-
-
- {item.profile_image ? (
-
- ) : (
-
- )}
-
-
- {item.display_name ||
- `${item.first_name} ${item.last_name}`.trim()}
-
-
- {item.user_name}
-
-
-
- {item.unread_messages_count ? (
-
- {item.unread_messages_count}
-
- ) : null}
-
-
- ))
- )}
+
+
+ {t("chats.title")}
+
+
+
+ setSearchText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") setSearch(searchText);
+ }}
+ placeholder={t("chats.searchPlaceholder")}
+ />
- )}
-
-
+ {isLoading ? (
+
+ ) : (
+
+
+
+
+
+
+
+ {t("chats.chatRoom")}
+
+ {t("chats.chatRoomDesc")}
+
+
+
+
+
+ {isEmpty ? (
+
+ {t("chats.empty")}
+
+ ) : (
+ data?.pages.map((page) =>
+ page.filteredUsersData?.map((item: IMessage) => {
+ const isOnline = isUserOnline(item.last_online, onlineLabel);
+ const storyMeta =
+ storyByUserId.get(String(item._id)) ??
+ storyByUserId.get(item.user_name);
+
+ return (
+
+
+
+
+
+ {item.display_name ||
+ `${item.first_name} ${item.last_name}`.trim()}
+
+
+
+ {item.user_name}
+
+
+
+
+ {resolveOnlineLabel(
+ item.last_online,
+ onlineLabel,
+ offlineLabel
+ )}
+
+
+
+
+ {item.unread_messages_count ? (
+
+ {item.unread_messages_count}
+
+ ) : null}
+
+
+ );
+ })
+ )
+ )}
+
+ )}
+
+
+
);
}
diff --git a/src/app/settings/chats/rooms/[roomId]/page.tsx b/src/app/settings/chats/rooms/[roomId]/page.tsx
new file mode 100644
index 0000000..0ca893a
--- /dev/null
+++ b/src/app/settings/chats/rooms/[roomId]/page.tsx
@@ -0,0 +1,714 @@
+"use client";
+
+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 ProfileAvatar from "@/components/main/ProfileAvatar";
+import VerificationBadge from "@/components/main/VerificationBadge";
+import useAxios from "@/hooks/useAxios";
+import { useUser } from "@/hooks/useUser";
+import {
+ acquireChatSocket,
+ joinGroupRoom,
+ joinUserRoom,
+ leaveGroupRoom,
+ releaseChatSocket,
+} from "@/lib/chat/socketClient";
+import MessageInput from "@/components/chat/MessageInput";
+import ChatActionBar from "@/components/chat/ChatActionBar";
+import AddChatRoomMembersModal from "@/components/chat/AddChatRoomMembersModal";
+import { groupReactions } from "@/lib/chat/reactions";
+import { useLongPress } from "@/hooks/useLongPress";
+import { useSwipeToReply } from "@/hooks/useSwipeToReply";
+import ChatBoldIcon from "@/components/chat/ChatBoldIcon";
+import BoldIcon from "@/components/ui/BoldIcon";
+import { motion } from "framer-motion";
+import IOSSpinner from "@/components/ui/IOSSpinner";
+import toast from "react-hot-toast";
+import { cn } from "@/lib/utils";
+import { getStoredUserId } from "@/lib/auth/session";
+import {
+ copyTextToClipboard,
+ getCopyableRoomMessageText,
+} from "@/lib/chat/getCopyableMessageText";
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+
+type RoomMember = {
+ _id: string;
+ user_name?: string;
+ first_name?: string;
+ last_name?: string;
+ profile_image?: string;
+ is_verified?: string;
+};
+
+type RoomMessage = {
+ _id: string;
+ roomId: string;
+ senderId: string;
+ content: string;
+ createdAt: string;
+ sender?: RoomMember;
+ reactions?: Array<{ userId: string; emoji: string }>;
+ replyTo?: {
+ _id: string;
+ content: string;
+ senderName?: string;
+ };
+};
+
+type RoomDetail = {
+ _id: string;
+ title: string;
+ description?: string;
+ image?: string;
+ onlineCount?: number;
+ visibility?: "public" | "private";
+ createdBy?: string | null;
+ members?: RoomMember[];
+};
+
+function mergeRoomMessages(
+ prev: RoomMessage[],
+ incoming: RoomMessage[]
+): RoomMessage[] {
+ const pending = prev.filter((m) => String(m._id).startsWith("temp-"));
+ const byId = new Map
();
+
+ for (const msg of incoming) {
+ byId.set(msg._id, msg);
+ }
+
+ for (const msg of pending) {
+ const matched = incoming.some(
+ (item) =>
+ item.content === msg.content &&
+ String(item.senderId) === String(msg.senderId)
+ );
+ if (!matched) {
+ byId.set(msg._id, msg);
+ }
+ }
+
+ const order = new Map(incoming.map((msg, index) => [msg._id, index]));
+ return Array.from(byId.values()).sort((a, b) => {
+ const ai = order.get(a._id);
+ const bi = order.get(b._id);
+ if (ai != null && bi != null) return ai - bi;
+ if (ai != null) return -1;
+ if (bi != null) return 1;
+ return 0;
+ });
+}
+
+function RoomMessageRow({
+ msg,
+ isMine,
+ senderName,
+ reactionGroups,
+ messageRefs,
+ onOpenAction,
+ onSwipeReply,
+ onScrollToReply,
+}: {
+ msg: RoomMessage;
+ isMine: boolean;
+ senderName: string;
+ reactionGroups: Array<{ emoji: string; count: number }>;
+ messageRefs: React.MutableRefObject>;
+ onOpenAction: (msg: RoomMessage) => void;
+ onSwipeReply: (msg: RoomMessage) => void;
+ onScrollToReply: () => void;
+}) {
+ const handleOpenActions = useCallback(() => {
+ onOpenAction(msg);
+ }, [msg, onOpenAction]);
+
+ const handleSwipeReply = useCallback(() => {
+ onSwipeReply(msg);
+ }, [msg, onSwipeReply]);
+
+ const { handlers: longPressHandlers, shouldBlockClick } = useLongPress(
+ handleOpenActions,
+ { delay: 2000 }
+ );
+
+ const { dragProps, replyOpacity, replyScale } = useSwipeToReply(
+ handleSwipeReply,
+ true,
+ "left"
+ );
+
+ const swipeDragStart =
+ "onDragStart" in dragProps ? dragProps.onDragStart : undefined;
+ const { onDragStart: _, ...restDragProps } = dragProps;
+
+ return (
+ {
+ if (el) messageRefs.current.set(msg._id, el);
+ else messageRefs.current.delete(msg._id);
+ }}
+ className={cn("flex w-full", isMine ? "justify-end" : "justify-start")}
+ >
+
+ {!isMine ? (
+
+
+
{senderName}
+
+
+ ) : null}
+ {msg.replyTo ? (
+
+
+ {msg.replyTo.senderName}
+
+ {msg.replyTo.content}
+
+ ) : null}
+
+
+
+
+
+ {
+ if (shouldBlockClick()) return;
+ }}
+ className={cn(
+ "rounded-[18px] px-3 py-2 text-[15px] leading-snug select-none touch-manipulation",
+ isMine ? "chat-bubble-out" : "chat-bubble-in"
+ )}
+ >
+
{msg.content}
+
+ {msg.createdAt}
+
+
+
+
+ {reactionGroups.length > 0 ? (
+
+ {reactionGroups.map(({ emoji, count }) => (
+
+ {emoji}
+ {count > 1 ? ` ${count}` : ""}
+
+ ))}
+
+ ) : null}
+
+
+ );
+}
+
+export default function ChatRoomThreadPage({
+ params,
+}: {
+ params: Promise<{ roomId: string }>;
+}) {
+ const { t } = useTranslation("common");
+ const resolvedParams = React.use(params);
+ const { roomId } = resolvedParams;
+ const { request } = useAxios();
+ const router = useRouter();
+ const user = useUser();
+ const [room, setRoom] = useState(null);
+ const [messages, setMessages] = useState([]);
+ const [newMessage, setNewMessage] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [sending, setSending] = useState(false);
+ const [replyingTo, setReplyingTo] = useState(null);
+ const [selectedMessage, setSelectedMessage] = useState(null);
+ const [actionMode, setActionMode] = useState(false);
+ const [showAddMembers, setShowAddMembers] = useState(false);
+ const scrollRef = useRef(null);
+ const headerRef = useRef(null);
+ const messageRefs = useRef(new Map());
+ const [headerOffset, setHeaderOffset] = useState(120);
+
+ const scrollToBottom = useCallback(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
+ }, []);
+
+ const scrollToMessage = useCallback((targetId: string) => {
+ const el = messageRefs.current.get(targetId);
+ const container = scrollRef.current;
+ if (!el || !container) {
+ toast.error(t("chats.toast.originalMessageNotFound"));
+ return;
+ }
+ const offset =
+ el.getBoundingClientRect().top -
+ container.getBoundingClientRect().top +
+ container.scrollTop -
+ container.clientHeight / 2;
+ container.scrollTo({ top: offset, behavior: "smooth" });
+ }, [t]);
+
+ const loadRoom = useCallback(async () => {
+ await request("POST", `/chat-rooms/${roomId}/join`, {}, { noToast: true });
+ const detail = await request<{ room: RoomDetail }>(
+ "GET",
+ `/chat-rooms/${roomId}`,
+ null,
+ { noToast: true }
+ );
+ setRoom(detail.room);
+ }, [request, roomId]);
+
+ const loadMessages = useCallback(async () => {
+ const res = await request<{ messages?: RoomMessage[] }>(
+ "GET",
+ `/chat-rooms/${roomId}/messages?limit=80`,
+ null,
+ { noToast: true }
+ );
+ const incoming = res?.messages ?? [];
+ setMessages((prev) => mergeRoomMessages(prev, incoming));
+ }, [request, roomId]);
+
+ useEffect(() => {
+ (async () => {
+ setLoading(true);
+ try {
+ await loadRoom();
+ await loadMessages();
+ } catch {
+ toast.error(t("chats.toast.loadRoomFailed"));
+ } finally {
+ setLoading(false);
+ }
+ })();
+ }, [loadRoom, loadMessages]);
+
+ useEffect(() => {
+ const headerEl = headerRef.current;
+ if (!headerEl) return;
+
+ const updateOffset = () => {
+ setHeaderOffset(headerEl.offsetHeight + 8);
+ };
+
+ updateOffset();
+ const observer = new ResizeObserver(updateOffset);
+ observer.observe(headerEl);
+ return () => observer.disconnect();
+ }, [room?.members?.length, room?.title]);
+
+ useEffect(() => {
+ const userId = String(user?._id ?? getStoredUserId()).trim();
+ if (!userId) return;
+
+ const socket = acquireChatSocket();
+ const syncRooms = () => {
+ joinUserRoom(userId);
+ joinGroupRoom(roomId, userId);
+ };
+
+ syncRooms();
+ socket.on("connect", syncRooms);
+ if (socket.connected) syncRooms();
+
+ const onNew = (msg: RoomMessage) => {
+ if (String(msg.roomId) !== String(roomId)) return;
+ setMessages((prev) => {
+ if (prev.some((m) => m._id === msg._id)) return prev;
+ const cleaned = prev.filter(
+ (m) =>
+ !String(m._id).startsWith("temp-") ||
+ m.content !== msg.content ||
+ String(m.senderId) !== String(msg.senderId)
+ );
+ return [...cleaned, msg];
+ });
+ };
+
+ socket.on("newGroupMessage", onNew);
+ const onDeleted = ({ roomId: deletedId }: { roomId: string }) => {
+ if (String(deletedId) === String(roomId)) {
+ toast.error(t("chats.toast.roomDeleted"));
+ router.replace("/settings/chats/rooms");
+ }
+ };
+ socket.on("chatRoomDeleted", onDeleted);
+
+ const onGroupReaction = ({
+ roomId: reactionRoomId,
+ messageId,
+ reactions,
+ }: {
+ roomId: string;
+ messageId: string;
+ reactions: RoomMessage["reactions"];
+ }) => {
+ if (String(reactionRoomId) !== String(roomId)) return;
+ setMessages((prev) =>
+ prev.map((m) => (m._id === messageId ? { ...m, reactions } : m))
+ );
+ };
+ socket.on("groupMessageReaction", onGroupReaction);
+
+ return () => {
+ socket.off("connect", syncRooms);
+ socket.off("newGroupMessage", onNew);
+ socket.off("chatRoomDeleted", onDeleted);
+ socket.off("groupMessageReaction", onGroupReaction);
+ leaveGroupRoom(roomId);
+ releaseChatSocket();
+ };
+ }, [roomId, user?._id, router]);
+
+ useEffect(() => {
+ if (loading) return;
+ const poll = window.setInterval(() => {
+ void loadMessages();
+ void loadRoom();
+ }, 8000);
+ return () => window.clearInterval(poll);
+ }, [loading, loadMessages, loadRoom]);
+
+ useEffect(() => {
+ scrollToBottom();
+ }, [messages.length, scrollToBottom]);
+
+ const sendMessage = async () => {
+ const text = newMessage.trim();
+ if (!text || sending) return;
+
+ const senderId = String(user?._id ?? getStoredUserId());
+ const tempId = `temp-${Date.now()}`;
+ const savedReply = replyingTo;
+ const replyPayload = savedReply
+ ? {
+ _id: savedReply._id,
+ content: savedReply.content,
+ senderName:
+ [savedReply.sender?.first_name, savedReply.sender?.last_name]
+ .filter(Boolean)
+ .join(" ") ||
+ savedReply.sender?.user_name ||
+ t("chats.user"),
+ }
+ : undefined;
+
+ const optimistic: RoomMessage = {
+ _id: tempId,
+ roomId,
+ senderId,
+ content: text,
+ createdAt: new Date().toLocaleTimeString("fa-IR"),
+ sender: user
+ ? {
+ _id: senderId,
+ first_name: user.first_name,
+ last_name: user.last_name,
+ user_name: user.user_name,
+ profile_image: user.profile_image,
+ is_verified: user.is_verified,
+ }
+ : undefined,
+ replyTo: replyPayload,
+ };
+
+ setMessages((prev) => [...prev, optimistic]);
+ setNewMessage("");
+ setReplyingTo(null);
+ setSending(true);
+
+ try {
+ const res = await request<{ message?: RoomMessage }>(
+ "POST",
+ `/chat-rooms/${roomId}/messages`,
+ {
+ content: text,
+ ...(replyPayload ? { replyToId: replyPayload._id } : {}),
+ }
+ );
+
+ if (res?.message?._id) {
+ setMessages((prev) =>
+ prev.map((m) => (m._id === tempId ? res.message! : m))
+ );
+ } else {
+ await loadMessages();
+ }
+ } catch (err: unknown) {
+ setMessages((prev) => prev.filter((m) => m._id !== tempId));
+ setNewMessage(text);
+ if (replyPayload && savedReply) {
+ setReplyingTo(savedReply);
+ }
+ const message =
+ (err as { response?: { data?: { error?: string; message?: string } } })
+ ?.response?.data?.error ||
+ (err as { response?: { data?: { message?: string } } })?.response?.data
+ ?.message;
+ toast.error(message || t("chats.toast.sendFailed"));
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const canAddMembers =
+ room?.visibility === "private" &&
+ room?.createdBy &&
+ String(room.createdBy) === String(user?._id);
+
+ const openActionFor = (msg: RoomMessage) => {
+ if (String(msg._id).startsWith("temp-")) return;
+ setSelectedMessage(msg);
+ setActionMode(true);
+ };
+
+ const closeActionMode = () => {
+ setActionMode(false);
+ setSelectedMessage(null);
+ };
+
+ const copySelectedMessage = async () => {
+ if (!selectedMessage) return;
+ const text = getCopyableRoomMessageText(selectedMessage.content);
+ if (!text) {
+ toast.error(t("chats.toast.copyEmpty"));
+ return;
+ }
+
+ const copied = await copyTextToClipboard(text);
+ if (copied) {
+ toast.success(t("chats.toast.copySuccess"));
+ closeActionMode();
+ return;
+ }
+
+ toast.error(t("chats.toast.copyFailed"));
+ };
+
+ const reactToMessage = async (emoji: string) => {
+ if (!selectedMessage?._id || selectedMessage._id.startsWith("temp-")) return;
+ try {
+ const res = await request<{ message?: RoomMessage }>(
+ "POST",
+ `/chat-rooms/${roomId}/messages/reaction`,
+ { messageId: selectedMessage._id, emoji }
+ );
+ const updated = res?.message;
+ if (updated?._id) {
+ setMessages((prev) =>
+ prev.map((m) => (m._id === updated._id ? { ...m, ...updated } : m))
+ );
+ }
+ closeActionMode();
+ } catch {
+ toast.error(t("chats.toast.reactFailed"));
+ }
+ };
+
+ return (
+
+
+
+
+
+ {loading ? (
+
+
+
+ ) : messages.length === 0 ? (
+
+ {t("chats.threadEmpty")}
+
+ ) : (
+
+ {messages.map((msg) => {
+ const isMine = String(msg.senderId) === String(user?._id);
+ const senderName =
+ [msg.sender?.first_name, msg.sender?.last_name]
+ .filter(Boolean)
+ .join(" ") ||
+ msg.sender?.user_name ||
+ t("chats.user");
+ const reactionGroups = groupReactions(msg.reactions);
+
+ return (
+ setReplyingTo(target)}
+ onScrollToReply={() => scrollToMessage(msg.replyTo!._id)}
+ />
+ );
+ })}
+
+ )}
+
+
+ {actionMode ? (
+ {
+ if (selectedMessage) setReplyingTo(selectedMessage);
+ closeActionMode();
+ }}
+ onCopy={copySelectedMessage}
+ onCancel={closeActionMode}
+ onReact={reactToMessage}
+ />
+ ) : (
+ {}}
+ handleVoiceUpload={() => {}}
+ isChatThread
+ replyingTo={replyingTo}
+ onCancelReply={() => setReplyingTo(null)}
+ onReplyPreviewClick={() => {
+ if (replyingTo?._id) scrollToMessage(replyingTo._id);
+ }}
+ replyLabel={
+ replyingTo
+ ? replyingTo.content?.slice(0, 80) || t("chats.message")
+ : undefined
+ }
+ />
+ )}
+
+ setShowAddMembers(false)}
+ roomId={roomId}
+ existingMemberIds={room?.members?.map((member) => member._id) ?? []}
+ onAdded={() => void loadRoom()}
+ />
+
+
+ );
+}
diff --git a/src/app/settings/chats/rooms/page.tsx b/src/app/settings/chats/rooms/page.tsx
new file mode 100644
index 0000000..426ebb7
--- /dev/null
+++ b/src/app/settings/chats/rooms/page.tsx
@@ -0,0 +1,162 @@
+"use client";
+
+import Container from "@/components/elements/Container";
+import CreateChatRoomModal from "@/components/chat/CreateChatRoomModal";
+import ProfileAvatar from "@/components/main/ProfileAvatar";
+import useAxios from "@/hooks/useAxios";
+import { useUser } from "@/hooks/useUser";
+import Link from "next/link";
+import { useCallback, useEffect, useState } from "react";
+import IOSSpinner from "@/components/ui/IOSSpinner";
+import BoldIcon from "@/components/ui/BoldIcon";
+import { usePageTitle } from "@/hooks/usePageTitle";
+import {
+ acquireChatSocket,
+ joinGroupRoom,
+ joinUserRoom,
+ releaseChatSocket,
+} from "@/lib/chat/socketClient";
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+
+type ChatRoomItem = {
+ _id: string;
+ title: string;
+ description?: string;
+ image?: string;
+ memberCount?: number;
+ onlineCount?: number;
+ isMember?: boolean;
+ visibility?: "public" | "private";
+ province?: { id?: number; name?: string } | null;
+ city?: { id?: number; name?: string } | null;
+ expertise?: string | null;
+ lastMessage?: { content?: string; createdAt?: string } | null;
+};
+
+export default function ChatRoomsPage() {
+ const { t } = useTranslation("common");
+ usePageTitle(t("chats.chatRoom"));
+ const { request } = useAxios();
+ const user = useUser();
+ const [rooms, setRooms] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showCreateModal, setShowCreateModal] = useState(false);
+
+ const loadRooms = useCallback(async () => {
+ try {
+ const res = await request<{ rooms?: ChatRoomItem[] }>(
+ "GET",
+ "/chat-rooms",
+ null,
+ { noToast: true }
+ );
+ setRooms(res?.rooms ?? []);
+ } finally {
+ setLoading(false);
+ }
+ }, [request]);
+
+ useEffect(() => {
+ void loadRooms();
+ }, [loadRooms]);
+
+ useEffect(() => {
+ const id = window.setInterval(() => {
+ void loadRooms();
+ }, 20_000);
+ return () => window.clearInterval(id);
+ }, [loadRooms]);
+
+ useEffect(() => {
+ const userId = user?._id ? String(user._id) : "";
+ if (!userId || rooms.length === 0) return;
+
+ acquireChatSocket();
+ joinUserRoom(userId);
+ for (const room of rooms) {
+ joinGroupRoom(room._id, userId);
+ }
+
+ return () => {
+ releaseChatSocket();
+ };
+ }, [rooms, user?._id]);
+
+ return (
+
+
+
+
{t("chats.chatRoom")}
+ setShowCreateModal(true)}
+ className="gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#0095f6] text-white shadow-md active:scale-95"
+ aria-label={t("chats.aria.createRoom")}
+ >
+
+
+
+
+
+ {loading ? (
+
+
+
+ ) : rooms.length === 0 ? (
+
+ {t("chats.roomsEmpty")}
+
+ ) : (
+
+ {rooms.map((room) => (
+
+
+
+
{room.title}
+
+ {room.visibility === "private"
+ ? t("chats.privateRoom")
+ : [room.city?.name, room.province?.name, room.expertise]
+ .filter(Boolean)
+ .join(" · ") || t("chats.forAllUsers")}
+
+ {room.lastMessage?.content ? (
+
+ {room.lastMessage.content}
+
+ ) : null}
+
+
+
+ {t("chats.onlineCount", { count: room.onlineCount ?? 0 })}
+
+
+ {t("chats.memberCount", { count: room.memberCount ?? 0 })}
+
+
+
+ ))}
+
+ )}
+
+
+ setShowCreateModal(false)}
+ onCreated={() => void loadRooms()}
+ />
+
+
+ );
+}
diff --git a/src/app/settings/edit/Authentication/page.tsx b/src/app/settings/edit/Authentication/page.tsx
index cfc4005..6e0adec 100644
--- a/src/app/settings/edit/Authentication/page.tsx
+++ b/src/app/settings/edit/Authentication/page.tsx
@@ -2,6 +2,7 @@
"use client";
import Container from "@/components/elements/Container";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import React, { useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useRouter } from "next/navigation";
@@ -9,23 +10,20 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
import Image from "next/image";
-import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
+import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import AuthRules from "@/components/auth/AuthRules";
+import { useTranslation } from "react-i18next";
function NationalCart() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
-
const [nationalCartImg, setNationalCartImg] = useState(null);
const [isCheckedOne, setIsCheckedOne] = useState(false);
const [isModalOpen, setModalOpen] = useState(false);
-
- const toggleCheckBox = () => setIsCheckedOne(!isCheckedOne);
-
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
- // انتخاب عکس از دوربین یا فایل
const selectImage = async (event: React.ChangeEvent) => {
if (event.target.files && event.target.files[0]) {
const file = event.target.files[0];
@@ -35,136 +33,102 @@ function NationalCart() {
}
};
- // حذف تصویر
- const removeImage = () => setNationalCartImg(null);
-
- // ارسال فرم
const uploadImage = async () => {
if (!nationalCartImg) {
- toast.error("لطفا تصویر کارت ملی را انتخاب کنید");
+ toast.error(t("settings.edit.authentication.idRequired"));
return;
}
if (!isCheckedOne) {
- toast.error("لطفا قوانین را مطالعه و تایید کنید");
+ toast.error(t("settings.edit.authentication.rulesRequired"));
return;
}
-
const formData = new FormData();
const fileInput = document.getElementById("fileInput") as HTMLInputElement;
if (fileInput?.files?.[0]) {
formData.append("national_card_image", fileInput.files[0]);
}
formData.append("mobile", mobile as string);
-
try {
await request("POST", "/verify/national_card_image", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
- toast.success("تصویر با موفقیت ارسال شد!");
+ toast.success(t("settings.edit.authentication.uploadSuccess"));
router.push("/settings/edit");
} catch (err) {
console.log("Upload error:", err);
- toast.error("خطا در ارسال تصویر!");
+ toast.error(t("settings.edit.authentication.uploadError"));
}
};
return (
-
-
-
احراز هویت
-
-
-
- برای ثبت درخواست، نیازمند احراز هویت شما هستیم
-
-
-
- {nationalCartImg ? (
- <>
-
-
-
-
- >
- ) : (
-
-
-
- )}
-
-
-
-
-
- اصل کارت ملی را بر روی سینه در دست گرفته و از خود عکس بگیرید
-
-
-
-
-
setModalOpen(true)}
- className="text-xs font-bold cursor-pointer"
- >
- تایید قوانین و مقررات
+
+
+
+
+ {t("settings.edit.nav.authentication")}
+
+
+ {t("settings.edit.authentication.intro")}
+
+
+ {nationalCartImg ? (
+ <>
+
+
setNationalCartImg(null)} className="p-4 absolute top-0 right-0">
+
+
+ >
+ ) : (
+
+
+
+ )}
+
+
+
+ {t("settings.edit.authentication.idHint")}
+
+
+ setIsCheckedOne(!isCheckedOne)} />
+ setModalOpen(true)} className="text-xs font-bold cursor-pointer">
+ {t("settings.edit.authentication.acceptRules")}
+
+
+
+ {t("settings.edit.authentication.submitContinue")}
+
+
router.push("/settings/edit")}
+ type="button"
+ >
+ {t("settings.edit.authentication.skip")}
+
+
-
-
- ثبت و ادامه
-
-
- router.push("/settings/edit")}
- type="button"
- >
- رد کن
-
-
-
-
-
+
+
);
}
diff --git a/src/app/settings/edit/License/page.tsx b/src/app/settings/edit/License/page.tsx
index 4011cd7..77305dc 100644
--- a/src/app/settings/edit/License/page.tsx
+++ b/src/app/settings/edit/License/page.tsx
@@ -14,8 +14,11 @@ import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import Cookies from "js-cookie";
import axios from "axios";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { useTranslation } from "react-i18next";
function LicensePage() {
+ const { t } = useTranslation("common");
const user = useUser();
const { loading } = useAxios();
const userId = user?._id; // فرض بر وجود user.id به عنوان _id
@@ -37,7 +40,7 @@ function LicensePage() {
// گرفتن توکن از کوکی
const token = Cookies.get("token");
if (!token) {
- toast.error("توکن یافت نشد، لطفاً دوباره وارد شوید");
+ toast.error(t("settings.edit.license.tokenMissing"));
return;
}
@@ -110,7 +113,7 @@ function LicensePage() {
if (event.target.files && event.target.files[0]) {
const file = event.target.files[0];
if (!file.type.startsWith("image/")) {
- toast.error("فقط فایلهای تصویری مجاز هستند");
+ toast.error(t("settings.edit.license.imageOnly"));
return;
}
try {
@@ -118,7 +121,7 @@ function LicensePage() {
const compressedBase64 = await compressImage(file, 800, 800, 0.7);
setAvatar(compressedBase64);
} catch (error) {
- toast.error("خطا در بارگذاری تصویر");
+ toast.error(t("settings.edit.license.loadError"));
console.error("Image compression error:", error);
} finally {
setLoadingUpload(false);
@@ -135,7 +138,7 @@ function LicensePage() {
const upload = async () => {
if (!avatar) {
- toast.error("لطفاً یک تصویر انتخاب کنید");
+ toast.error(t("settings.edit.license.selectImage"));
return;
}
@@ -145,7 +148,7 @@ function LicensePage() {
// گرفتن توکن از کوکی
const token = Cookies.get("token");
if (!token) {
- toast.error("توکن یافت نشد، لطفاً دوباره وارد شوید");
+ toast.error(t("settings.edit.license.tokenMissing"));
return;
}
@@ -160,12 +163,12 @@ function LicensePage() {
);
if (response.status === 201) {
- toast.success("تصویر با موفقیت آپلود شد");
+ toast.success(t("settings.edit.license.uploadSuccess"));
setLicense(response.data.license);
}
} catch (error) {
- toast.error("خطا در آپلود تصویر");
+ toast.error(t("settings.edit.license.uploadError"));
console.error( error);
} finally {
setLoadingUpload(false);
@@ -175,9 +178,10 @@ function LicensePage() {
return (
+
-
بارگذاری مجوز
+
{t("settings.edit.license.title")}
- برای ثبت درخواست نیازمند احراز هویت شما هستیم
+ {t("settings.edit.license.intro")}
@@ -250,7 +254,7 @@ function LicensePage() {
{license && (
-
وضعیت تأیید: {license.Confirmation ? "تأیید شده" : "در انتظار تأیید"}
+
{t("settings.edit.license.confirmStatus")}: {license.Confirmation ? t("settings.edit.license.confirmed") : t("settings.edit.license.pending")}
)}
>
@@ -258,7 +262,7 @@ function LicensePage() {
- برای دریافت تیک طلایی مجوز خود را بارگذاری کنید
+ {t("settings.edit.license.uploadHint")}
@@ -283,16 +287,14 @@ function LicensePage() {
- ثبت و ادامه
+ {t("settings.edit.license.submitContinue")}
- زمان تایید مدارک 5 دقیقه تا 1 ساعت در ساعات اداری و
-
-
- 3 تا 8 ساعت در ساعات غیر اداری
+ {t("settings.edit.license.reviewTime")}
+
);
}
diff --git a/src/app/settings/edit/avatar/page.tsx b/src/app/settings/edit/avatar/page.tsx
index ca2135d..bafffab 100644
--- a/src/app/settings/edit/avatar/page.tsx
+++ b/src/app/settings/edit/avatar/page.tsx
@@ -2,20 +2,22 @@
import AuthNextButton from "@/components/auth/AuthNextButton";
import Container from "@/components/elements/Container";
-import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { useUser } from "@/hooks/useUser";
import Image from "next/image";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
function AvatarPage() {
+ const { t } = useTranslation("common");
const router = useRouter();
const user = useUser();
const [avatar, setAvatar] = useState(null);
const [loadingUpload, setLoadingUpload] = useState(false);
-
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
@@ -24,7 +26,6 @@ function AvatarPage() {
setAvatar(user?.profile_image || null);
}, [user]);
- // Select image
const selectImage = async (event: React.ChangeEvent) => {
if (event.target.files && event.target.files[0]) {
const file = event.target.files[0];
@@ -34,29 +35,21 @@ function AvatarPage() {
}
};
- // Upload image
const uploadImage = async () => {
if (!avatar) {
- toast.error("لطفا تصویر پروفایل را انتخاب کنید");
+ toast.error(t("settings.edit.avatar.required"));
} else {
setLoadingUpload(true);
-
const formData = new FormData();
- // اطمینان حاصل کنید که داده تصویر درست به سرور ارسال میشود
- const fileInput = document.getElementById(
- "fileInput"
- ) as HTMLInputElement;
+ const fileInput = document.getElementById("fileInput") as HTMLInputElement;
if (fileInput?.files?.[0]) {
formData.append("profile_image", fileInput.files[0]);
}
formData.append("mobile", mobile as string);
-
try {
if (avatar.startsWith("data:")) {
await request("PATCH", "/verify/profile_image", formData, {
- headers: {
- "Content-Type": "multipart/form-data",
- },
+ headers: { "Content-Type": "multipart/form-data" },
});
router.push("/settings/edit");
} else {
@@ -69,83 +62,55 @@ function AvatarPage() {
}
};
- // Remove image
- const removeImage = () => {
- setAvatar(null);
- };
-
-
+ const removeImage = () => setAvatar(null);
return (
-
-
-
-
تصویر پروفایل
-
- {avatar ? (
- <>
-
-
-
-
- >
- ) : (
-
-
-
- )}
-
-
+
+
+
+
+
+ {t("settings.edit.nav.avatar")}
+
+
+ {avatar ? (
+ <>
+
+
+
+
+ >
+ ) : (
+
+
+
+ )}
-
-
- ویرایش تصویر
-
-
-
-
- ویرایش
-
-
-
+
+
+
+
+ {t("settings.edit.avatar.editImage")}
+
+
+
+ {t("settings.edit.save")}
+
+
+
+
);
}
diff --git a/src/app/settings/edit/bio/page.tsx b/src/app/settings/edit/bio/page.tsx
index 7532d5e..a9cbf9f 100644
--- a/src/app/settings/edit/bio/page.tsx
+++ b/src/app/settings/edit/bio/page.tsx
@@ -2,7 +2,8 @@
"use client";
import Container from "@/components/elements/Container";
-import React, { useEffect } from "react";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import React, { useEffect, useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
@@ -10,18 +11,22 @@ import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import { useUser } from "@/hooks/useUser";
+import { useTranslation } from "react-i18next";
-// Validation schema using Yup
-const schema = yup.object().shape({
- bio: yup.string().required("نوشتن bio الزامی است"),
-});
-
-function PublicRelations() {
+function BioPage() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
-
const user = useUser();
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ bio: yup.string().max(500, t("settings.edit.bio.maxLength")),
+ }),
+ [t]
+ );
+
const formik = useFormik({
initialValues: {
bio: "",
@@ -35,12 +40,13 @@ function PublicRelations() {
});
router.push("/settings/edit");
} catch (err: any) {
- toast.error(err?.message || "خطای نامشخصی رخ داد.");
+ toast.error(err?.message || t("settings.edit.unknownError"));
} finally {
setSubmitting(false);
}
},
});
+
useEffect(() => {
formik.setValues({
bio: user?.bio ?? "",
@@ -48,40 +54,47 @@ function PublicRelations() {
}, [user]);
return (
-
-
-
بیو
-
-
- در مورد خودتان چیزی بگویید
-
- {formik.touched.bio && formik.errors.bio && (
-
- {formik.errors.bio}
+
+
+
+
+ {t("settings.edit.nav.bio")}
+
+
+
+
+ {t("settings.edit.bio.aboutYou")}
- )}
+
+ {formik.touched.bio && formik.errors.bio && (
+
+ {formik.errors.bio}
+
+ )}
-
- ویرایش
-
-
-
-
+
+ {t("settings.edit.save")}
+
+
+
+
+
);
}
-export default PublicRelations;
+export default BioPage;
diff --git a/src/app/settings/edit/colors/page.tsx b/src/app/settings/edit/colors/page.tsx
index b588971..7feea05 100644
--- a/src/app/settings/edit/colors/page.tsx
+++ b/src/app/settings/edit/colors/page.tsx
@@ -10,14 +10,20 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import Image from "next/image";
import { useUser } from "@/hooks/useUser";
-
-// Validation schema using Yup
-const schema = yup.object().shape({
- selectedHairImage: yup.string().required("رنگ مو الزامی است"),
- selectedEyeImage: yup.string().required(" رنگ چشم الزامی است"),
-});
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { useTranslation } from "react-i18next";
+import { useMemo } from "react";
function Colors() {
+ const { t } = useTranslation("common");
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ selectedHairImage: yup.string().required(t("settings.edit.colors.hairRequired")),
+ selectedEyeImage: yup.string().required(t("settings.edit.colors.eyeRequired")),
+ }),
+ [t]
+ );
const [selectedButton, setSelectedButton] = useState
(1); // Default: 1 for "قد"
const [selectedHairImage, setSelectedHairImage] = useState("");
const [selectedEyeImage, setSelectedEyeImage] = useState("");
@@ -82,11 +88,11 @@ function Colors() {
};
return (
+
-
سایز
-
-
مشخصات ظاهری
+
{t("settings.edit.nav.colors")}
+
{t("settings.edit.sizes.appearance")}
{/* Buttons for categories */}
@@ -98,7 +104,7 @@ function Colors() {
}`}
onClick={() => handleButtonPress(1)}
>
- رنگ چشم
+ {t("settings.edit.colors.eyeColor")}
handleButtonPress(2)}
>
- رنگ مو
+ {t("settings.edit.colors.hairColor")}
@@ -161,10 +167,11 @@ function Colors() {
className="mt-10"
loading={loading} disabled={loading}
>
- ویرایش
+ {t("settings.edit.save")}
+
);
}
diff --git a/src/app/settings/edit/cooperation-type/page.tsx b/src/app/settings/edit/cooperation-type/page.tsx
index af54b47..1b658a2 100644
--- a/src/app/settings/edit/cooperation-type/page.tsx
+++ b/src/app/settings/edit/cooperation-type/page.tsx
@@ -2,7 +2,8 @@
"use client";
import Container from "@/components/elements/Container";
-import React, { useEffect } from "react";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import React, { useEffect, useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
@@ -10,21 +11,24 @@ import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import { useUser } from "@/hooks/useUser";
-
-// Validation schema using Yup
-const schema = yup.object().shape({
- selectedButton: yup.boolean().required("انتخاب نوع همکاری الزامی است"),
-});
+import { useTranslation } from "react-i18next";
function CooperationType() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const user = useUser();
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ selectedButton: yup.boolean().required(t("settings.edit.cooperationType.required")),
+ }),
+ [t]
+ );
+
const formik = useFormik({
- initialValues: {
- selectedButton: null as boolean | null,
- },
+ initialValues: { selectedButton: null as boolean | null },
validationSchema: schema,
onSubmit: async (values, { setSubmitting }) => {
try {
@@ -34,7 +38,7 @@ function CooperationType() {
});
router.push("/settings/edit");
} catch (err: any) {
- toast.error(err?.message || "خطای نامشخصی رخ داد.");
+ toast.error(err?.message || t("settings.edit.unknownError"));
} finally {
setSubmitting(false);
}
@@ -46,55 +50,46 @@ function CooperationType() {
}, [user?.cooperation_abroad]);
return (
-
-
-
نوع همکاری
-
-
- آیا مایل به همکاری خارج از محل سکونت خود هستید؟
-
-
-
-
formik.setFieldValue("selectedButton", true)}
- className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
- formik.values.selectedButton === true
- ? "btn-modern--selected text-white"
- : ""
- }`}
- >
- بله خوشحال هم می شم
-
-
formik.setFieldValue("selectedButton", false)}
- className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
- formik.values.selectedButton === false
- ? "btn-modern--selected text-white"
- : ""
- }`}
- >
- نه شهر خودم رو ترجیح میدم
-
-
-
- {/* Error Message */}
- {formik.errors.selectedButton && formik.touched.selectedButton && (
-
- {formik.errors.selectedButton}
+
+
+
+
+ {t("settings.edit.nav.cooperationType")}
+
+
+ {t("settings.edit.cooperationType.question")}
+
+
+
+
formik.setFieldValue("selectedButton", true)}
+ className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
+ formik.values.selectedButton === true ? "btn-modern--selected text-white" : ""
+ }`}
+ >
+ {t("settings.edit.cooperationType.yes")}
+
+
formik.setFieldValue("selectedButton", false)}
+ className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
+ formik.values.selectedButton === false ? "btn-modern--selected text-white" : ""
+ }`}
+ >
+ {t("settings.edit.cooperationType.no")}
+
- )}
-
-
- ویرایش
-
-
-
-
+ {formik.errors.selectedButton && formik.touched.selectedButton && (
+ {formik.errors.selectedButton}
+ )}
+
+ {t("settings.edit.save")}
+
+
+
+
+
);
}
diff --git a/src/app/settings/edit/expertise/page.tsx b/src/app/settings/edit/expertise/page.tsx
index 95aa21a..287b093 100644
--- a/src/app/settings/edit/expertise/page.tsx
+++ b/src/app/settings/edit/expertise/page.tsx
@@ -2,36 +2,61 @@
"use client";
import Container from "@/components/elements/Container";
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
import useAxios from "@/hooks/useAxios";
import * as yup from "yup";
-import { useRouter } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import { IExpertise } from "@/types/types";
import { useUser } from "@/hooks/useUser";
import ExpertisePicker from "@/components/auth/ExpertisePicker";
-
-const schema = yup.object().shape({
- subExpertise: yup
- .array()
- .of(yup.string().required("حداقل یک زیرمهارت را انتخاب کنید"))
- .min(1, "حداقل یک زیرمهارت را انتخاب کنید"),
- expertise: yup.string().required("انتخاب نوع تخصص الزامی است"),
-});
+import {
+ resolveDisplaySubExpertise,
+ subExpertiseListIncludes,
+} from "@/lib/subExpertiseDisplay";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { useTranslation } from "react-i18next";
function Expertise() {
+ const { t } = useTranslation("common");
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ subExpertise: yup
+ .array()
+ .of(yup.string().required(t("settings.edit.expertise.subskillRequired")))
+ .min(1, t("settings.edit.expertise.subskillRequired")),
+ displaySubExpertise: yup
+ .string()
+ .nullable()
+ .required(t("settings.edit.expertise.subspecialtyRequired")),
+ expertise: yup.string().required(t("settings.edit.expertise.typeRequired")),
+ }),
+ [t]
+ );
const [expertise, setExpertise] = useState("");
const [expertiseList, setExpertiseList] = useState(null);
const [subExpertise, setSubExpertise] = useState([]);
+ const [displaySubExpertise, setDisplaySubExpertise] = useState(
+ null
+ );
const user = useUser();
+ const hydratedFromProfile = useRef(false);
useEffect(() => {
- setExpertise(user?.expertise || "");
- setSubExpertise(user?.sub_expertise || []);
+ if (hydratedFromProfile.current || !user?.user_name) return;
+
+ setExpertise(user.expertise || "");
+ setSubExpertise(Array.isArray(user.sub_expertise) ? [...user.sub_expertise] : []);
+ setDisplaySubExpertise(
+ resolveDisplaySubExpertise(user.sub_expertise, user.display_sub_expertise)
+ );
+ hydratedFromProfile.current = true;
}, [user]);
const router = useRouter();
+ const searchParams = useSearchParams();
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
@@ -52,10 +77,44 @@ function Expertise() {
fetchData();
}, []);
+ const handleSubExpertiseToggle = (value: string) => {
+ setSubExpertise((prev) => {
+ const next = prev.includes(value)
+ ? prev.filter((item) => item !== value)
+ : [...prev, value];
+
+ setDisplaySubExpertise((current) => {
+ if (current === value && !next.includes(value)) {
+ return next[0] ?? null;
+ }
+ if (current && !next.includes(current)) {
+ return next[0] ?? null;
+ }
+ if (!current && next.length === 1) {
+ return next[0];
+ }
+ return current;
+ });
+
+ return next;
+ });
+ };
+
const handleCheck = async () => {
+ const resolvedDisplay = subExpertiseListIncludes(
+ subExpertise,
+ displaySubExpertise
+ )
+ ? displaySubExpertise
+ : resolveDisplaySubExpertise(subExpertise, displaySubExpertise);
+
try {
- await schema.validate({ expertise, subExpertise });
- await sendExpertiseTypeHandler();
+ await schema.validate({
+ expertise,
+ subExpertise,
+ displaySubExpertise: resolvedDisplay,
+ });
+ await sendExpertiseTypeHandler(resolvedDisplay);
} catch (validationError: any) {
toast.error(validationError.message, {
duration: 4000,
@@ -63,43 +122,53 @@ function Expertise() {
}
};
- const sendExpertiseTypeHandler = async () => {
+ const sendExpertiseTypeHandler = async (resolvedDisplay: string | null) => {
try {
const data = {
mobile,
expertise,
sub_expertise: subExpertise,
+ display_sub_expertise: resolvedDisplay,
};
await request("PATCH", "/verify/expertise", data);
localStorage.setItem("expertise", expertise);
- router.push("/settings/edit");
+ if (resolvedDisplay) {
+ localStorage.setItem("display_sub_expertise", resolvedDisplay);
+ }
+ toast.success(t("settings.edit.expertise.updated"));
+ const redirect = searchParams.get("redirect");
+ if (redirect && redirect.startsWith("/") && !redirect.startsWith("//")) {
+ router.push(redirect);
+ } else {
+ router.push("/settings/edit");
+ }
} catch (error: any) {
- toast.error(error?.message || "خطای نامشخصی رخ داد.");
+ toast.error(error?.message || t("settings.edit.unknownError"));
}
};
return (
+
-
تخصص
+
+ {t("settings.edit.nav.expertise")}
+
-
در چه زمینه ای تخصص دارید؟
+
{t("settings.edit.expertise.question")}
{
setExpertise(value);
setSubExpertise([]);
+ setDisplaySubExpertise(null);
}}
- onSubExpertiseToggle={(value) => {
- setSubExpertise((prev) =>
- prev.includes(value)
- ? prev.filter((item) => item !== value)
- : [...prev, value]
- );
- }}
+ onSubExpertiseToggle={handleSubExpertiseToggle}
+ onDisplaySubExpertiseChange={setDisplaySubExpertise}
/>
- ویرایش
+ {t("settings.edit.save")}
+
);
}
diff --git a/src/app/settings/edit/google-account/page.tsx b/src/app/settings/edit/google-account/page.tsx
new file mode 100644
index 0000000..151ba69
--- /dev/null
+++ b/src/app/settings/edit/google-account/page.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import Container from "@/components/elements/Container";
+import PageTitle from "@/components/settings/PageTitle";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { GoogleSignInButton } from "@/app/(auth)/AuthProviders";
+import useAxios from "@/hooks/useAxios";
+import React, { useCallback, useEffect, useState } from "react";
+import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
+
+type GoogleStatus = {
+ linked: boolean;
+ email: string | null;
+ auth_provider?: string;
+};
+
+export default function GoogleAccountSettingsPage() {
+ const { t } = useTranslation("common");
+ const { request } = useAxios();
+ const [status, setStatus] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const loadStatus = useCallback(async () => {
+ setLoading(true);
+ try {
+ const data = (await request("GET", "/auth/google/status")) as GoogleStatus;
+ setStatus(data);
+ } catch {
+ toast.error(t("settings.edit.googleAccount.loadError"));
+ } finally {
+ setLoading(false);
+ }
+ }, [request, t]);
+
+ useEffect(() => {
+ loadStatus();
+ }, [loadStatus]);
+
+ return (
+
+
+ {t("settings.edit.nav.googleAccount")}
+
+ {loading ? (
+
{t("common.loading")}
+ ) : status?.linked ? (
+ <>
+
+ {t("settings.edit.googleAccount.linkedHint")}
+
+ {status.email ? (
+
+ {status.email}
+
+ ) : null}
+ >
+ ) : (
+ <>
+
+ {t("settings.edit.googleAccount.unlinkedHint")}
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/app/settings/edit/layout.tsx b/src/app/settings/edit/layout.tsx
index 40a9cee..77ca914 100644
--- a/src/app/settings/edit/layout.tsx
+++ b/src/app/settings/edit/layout.tsx
@@ -1,11 +1,9 @@
import type { Metadata } from "next";
-import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
-export const metadata: Metadata = generatePageMetadata({
- title: "ویرایش پروفایل | مدستاگرام",
- description: "ویرایش اطلاعات و تنظیمات پروفایل در مدستاگرام",
- path: "/settings/edit",
-});
+export async function generateMetadata(): Promise {
+ return generateSeoPageMetadata("settingsEdit", { index: false });
+}
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
diff --git a/src/app/settings/edit/location/page.tsx b/src/app/settings/edit/location/page.tsx
index ac9879d..9c4291b 100644
--- a/src/app/settings/edit/location/page.tsx
+++ b/src/app/settings/edit/location/page.tsx
@@ -16,16 +16,22 @@ import Map, { GeolocateControl, Marker } from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import Image from "next/image";
import { useUser } from "@/hooks/useUser";
-
-// Validation schema using Yup
-const schema = yup.object().shape({
- markerCoordinate: yup.mixed().required(" انتخاب لوکیشن الزامی است"),
- address: yup.string().required(" آدرس الزامی است"),
- cityId: yup.string().required(" انتخاب شهر الزامی است"),
- stateId: yup.string().required(" انتخاب استان الزامی است"),
-});
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { useTranslation } from "react-i18next";
+import { useMemo } from "react";
function LocationPage() {
+ const { t } = useTranslation("common");
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ markerCoordinate: yup.mixed().required(t("settings.edit.location.markerRequired")),
+ address: yup.string().required(t("settings.edit.location.addressRequired")),
+ cityId: yup.string().required(t("settings.edit.location.cityRequired")),
+ stateId: yup.string().required(t("settings.edit.location.stateRequired")),
+ }),
+ [t]
+ );
const router = useRouter();
const { request, loading } = useAxios();
const user = useUser();
@@ -95,7 +101,7 @@ function LocationPage() {
});
router.push("/settings/edit");
} catch (err: any) {
- toast.error(err?.message || "خطای نامشخصی رخ داد.");
+ toast.error(err?.message || t("settings.edit.unknownError"));
} finally {
setSubmitting(false);
}
@@ -146,13 +152,14 @@ function LocationPage() {
}, [user]);
return (
+
-
لوکیشن
+
{t("settings.edit.nav.location")}
- مشخص کنید در کدام شهر قادر به انجام فعالیت هستید
+ {t("settings.edit.location.question")}
- استان
+ {t("settings.profile.province")}
{allStates?.map((item: IProvince) => (
@@ -192,7 +199,7 @@ function LocationPage() {
onChange={(e) => formik.setFieldValue("cityId", e.target.value)}
>
- شهر
+ {t("settings.profile.city")}
{cities?.map((city: ICity) => (
@@ -208,7 +215,7 @@ function LocationPage() {
- اطلاعات لوکیشن شما برای همه قابل نمایش باشد
+ {t("settings.edit.location.showToAll")}
- ویرایش
+ {t("settings.edit.save")}
+
);
}
diff --git a/src/app/settings/edit/page.tsx b/src/app/settings/edit/page.tsx
index 5ea1fdd..4f2fa36 100644
--- a/src/app/settings/edit/page.tsx
+++ b/src/app/settings/edit/page.tsx
@@ -2,14 +2,36 @@
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
-import UserDetails from "@/components/settings/UserDetails";
import { editUserNavLinks } from "@/constants";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import { useUser } from "@/hooks/useUser";
import Image from "next/image";
import Link from "next/link";
import React, { useEffect, useState } from "react";
+import UserDetails from "@/components/settings/UserDetails";
+import { staticIconUrl } from "@/components/main/BaseUrl";
+import { useTranslation } from "react-i18next";
+
+const EDIT_NAV_KEY: Record = {
+ "/username": "username",
+ "/password": "password",
+ "/google-account": "googleAccount",
+ "/two-factor": "twoFactor",
+ "/avatar": "avatar",
+ "/Authentication": "authentication",
+ "/expertise": "expertise",
+ "/colors": "colors",
+ "/services": "services",
+ "/sizes": "sizes",
+ "/License": "license",
+ "/cooperation-type": "cooperationType",
+ "/public-relations": "publicRelations",
+ "/shaba": "shaba",
+ "/location": "location",
+};
function EditPage() {
+ const { t } = useTranslation("common");
const user = useUser();
const [isRegister, setIsRegister] = useState("false");
@@ -18,32 +40,39 @@ function EditPage() {
}, [user]);
return (
-
- ویرایش
-
-
-
- {editUserNavLinks?.map((item) => (
-
-
-
{item?.title}
-
- ))}
+
+
+ {t("settings.nav.edit")}
+
+
+
+ {editUserNavLinks?.map((item) => (
+
+
+
+ {t(`settings.edit.nav.${EDIT_NAV_KEY[item.href]}`)}
+
+
+ ))}
+
-
-
+
+
);
}
diff --git a/src/app/settings/edit/password/page.tsx b/src/app/settings/edit/password/page.tsx
index f95a9d8..3b5bb4a 100644
--- a/src/app/settings/edit/password/page.tsx
+++ b/src/app/settings/edit/password/page.tsx
@@ -4,7 +4,8 @@
import AuthHead from "@/components/auth/AuthHead";
import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
import Container from "@/components/elements/Container";
-import React from "react";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import React, { useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
@@ -13,28 +14,10 @@ import { IVerifyOtp } from "@/types/types";
import AuthNextButton from "@/components/auth/AuthNextButton";
import { useUser } from "@/hooks/useUser";
import toast from "react-hot-toast";
-
-// Validation schema using Yup
-const schema = yup.object().shape({
- password: yup
- .string()
- .required("کلمه عبور نمی\u200Cتواند خالی باشد")
- .min(8, "کلمه عبور نباید کوتاه تر از 8 کاراکتر باشد")
- .matches(
- /^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/,
- "کلمه عبور باید شامل حروف و اعداد باشد"
- ),
- confirmPassword: yup
- .string()
- .required("تکرار کلمه عبور نمی\u200Cتواند خالی باشد")
- .oneOf(
- [yup.ref("password")],
- "تکرار کلمه عبور باید با کلمه عبور مطابقت داشته باشد"
- )
- .required("تکرار کلمه عبور الزامی است"),
-});
+import { useTranslation } from "react-i18next";
function PasswordPage() {
+ const { t } = useTranslation("common");
const router = useRouter();
const user = useUser();
const { request, loading } = useAxios();
@@ -44,6 +27,26 @@ function PasswordPage() {
: null;
const isGoogleUser = authProvider === "google" || user?.auth_provider === "google";
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ password: yup
+ .string()
+ .required(t("settings.edit.password.required"))
+ .min(8, t("settings.edit.password.minLength"))
+ .matches(
+ /^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/,
+ t("settings.edit.password.mustIncludeLettersNumbers")
+ ),
+ confirmPassword: yup
+ .string()
+ .required(t("settings.edit.password.repeatRequired"))
+ .oneOf([yup.ref("password")], t("settings.edit.password.repeatMismatch"))
+ .required(t("settings.edit.password.repeatRequiredField")),
+ }),
+ [t]
+ );
+
const formik = useFormik({
initialValues: {
password: "",
@@ -58,8 +61,8 @@ function PasswordPage() {
})) as IVerifyOtp;
toast.success(
isGoogleUser
- ? "کلمه عبور تنظیم شد. از این پس میتوانید با نام کاربری وارد شوید."
- : "کلمه عبور با موفقیت تغییر کرد"
+ ? t("settings.edit.password.setSuccess")
+ : t("settings.edit.password.changeSuccess")
);
router.push("/settings/edit");
} catch (err: any) {
@@ -69,63 +72,70 @@ function PasswordPage() {
});
return (
-
-
-
- {isGoogleUser && (
-
- حساب شما با گوگل ساخته شده است. در صورت تمایل میتوانید کلمه عبور
- جداگانه تنظیم کنید.
-
- )}
-
-
+
+
+
- {formik.touched.password && formik.errors.password && (
-
- {formik.errors.password}
-
+ {isGoogleUser && (
+
+ {t("settings.edit.password.googleHint")}
+
)}
+
+
+ {formik.touched.password && formik.errors.password && (
+
+ {formik.errors.password}
+
+ )}
-
- {formik.touched.confirmPassword && formik.errors.confirmPassword && (
-
- {formik.errors.confirmPassword}
-
- )}
+
+ {formik.touched.confirmPassword && formik.errors.confirmPassword && (
+
+ {formik.errors.confirmPassword}
+
+ )}
-
- تایید کلمه عبور
-
-
-
-
+
+ {t("settings.edit.password.confirm")}
+
+
+
+
+
);
}
diff --git a/src/app/settings/edit/public-relations/page.tsx b/src/app/settings/edit/public-relations/page.tsx
index c499591..ebe9815 100644
--- a/src/app/settings/edit/public-relations/page.tsx
+++ b/src/app/settings/edit/public-relations/page.tsx
@@ -2,7 +2,8 @@
"use client";
import Container from "@/components/elements/Container";
-import React, { useEffect } from "react";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import React, { useEffect, useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
@@ -10,19 +11,22 @@ import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import { useUser } from "@/hooks/useUser";
-
-// Validation schema using Yup
-const schema = yup.object().shape({
- bio: yup.string().required("نوشتن bio الزامی است"),
- // selectedButton: yup.boolean().required("لطفا یکی از گزینه ها را انتخاب کنید"),
-});
+import { useTranslation } from "react-i18next";
function PublicRelations() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
-
const user = useUser();
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ bio: yup.string().required(t("settings.edit.publicRelations.bioRequired")),
+ }),
+ [t]
+ );
+
const formik = useFormik({
initialValues: {
selectedButton: null as boolean | null,
@@ -37,12 +41,13 @@ function PublicRelations() {
});
router.push("/settings/edit");
} catch (err: any) {
- toast.error(err?.message || "خطای نامشخصی رخ داد.");
+ toast.error(err?.message || t("settings.edit.unknownError"));
} finally {
setSubmitting(false);
}
},
});
+
useEffect(() => {
formik.setValues({
selectedButton: user?.conversation_projects ?? null,
@@ -51,84 +56,43 @@ function PublicRelations() {
}, [user]);
return (
-
-
-
روابط عمومی
-
-
- {/*
- مایلید پروژه هایی گفتگو محور به شما پیشنهاد شود؟
-
*/}
-
- {/*
-
formik.setFieldValue("selectedButton", true)}
- className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
- formik.values.selectedButton === true
- ? "btn-modern--selected text-white"
- : ""
+
+
+
+
+ {t("settings.edit.nav.publicRelations")}
+
+
+ {t("settings.edit.bio.aboutYou")}
+
- روابط عمومی بالایی دارم
-
- formik.setFieldValue("selectedButton", false)}
- className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
- formik.values.selectedButton === false
- ? "btn-modern--selected text-white"
- : ""
- }`}
- >
- اهل معاشرت نیستم
-
-
- {/* Error Message */}
- {/* {formik.errors.selectedButton && formik.touched.selectedButton && ( */}
- {/* */}
- {/* {formik.errors.selectedButton} */}
- {/*
*/}
- {/* )} */}
- در مورد خودتان چیزی بگویید
- {
- // محدود کردن به 500 کاراکتر
- if (e.target.value.length <= 500) {
- formik.setFieldValue("bio", e.target.value);
- }
- }}
- onBlur={formik.handleBlur}
- />
-
- {/* شمارشگر کاراکتر */}
-
- {formik.values.bio.length}/500
-
-
- {formik.touched.bio && formik.errors.bio && (
-
- {formik.errors.bio}
+ value={formik.values.bio}
+ onChange={(e) => {
+ if (e.target.value.length <= 500) {
+ formik.setFieldValue("bio", e.target.value);
+ }
+ }}
+ onBlur={formik.handleBlur}
+ />
+
+ {formik.values.bio.length}/500
- )}
-
-
- ویرایش
-
-
-
-
+ {formik.touched.bio && formik.errors.bio && (
+ {formik.errors.bio}
+ )}
+
+ {t("settings.edit.save")}
+
+
+
+
+
);
}
diff --git a/src/app/settings/edit/services/page.tsx b/src/app/settings/edit/services/page.tsx
index 06bacc3..c39dc95 100644
--- a/src/app/settings/edit/services/page.tsx
+++ b/src/app/settings/edit/services/page.tsx
@@ -6,13 +6,16 @@ import ServiceItem from "@/components/main/Services/ServiceItem";
import AddServiceModal from "@/components/main/Services/AddServiceModal";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
import Container from "@/components/elements/Container";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import AuthNextButton from "@/components/auth/AuthNextButton";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import { dataURLtoBlob } from "@/helpers/helpers";
import { useRouter } from "next/navigation";
+import { useTranslation } from "react-i18next";
const ServicesPage: React.FC = () => {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const [services, setServices] = useState
([]);
@@ -21,15 +24,13 @@ const ServicesPage: React.FC = () => {
const fetchUser = async () => {
try {
- const response = await request<{ user: User }>(
- "GET",
- "/profile?services=1"
- );
+ const response = await request<{ user: User }>("GET", "/profile?services=1");
setUser(response?.user);
} catch (error) {
console.error("Failed to fetch user:", error);
}
};
+
useEffect(() => {
fetchUser();
}, []);
@@ -37,51 +38,36 @@ const ServicesPage: React.FC = () => {
useEffect(() => {
setServices(user?.services || []);
}, [user]);
+
const handleAddService = (newService: Service) => {
setServices([...services, newService]);
};
+
const handleDeleteService = (id: string) => {
- setServices(
- services.filter((service) => service.id !== id && service._id !== id)
- );
+ setServices(services.filter((service) => service.id !== id && service._id !== id));
};
const handleSubmit = async () => {
if (services.length === 0) {
- toast.error("لطفاً حداقل یک خدمت وارد کنید.");
+ toast.error(t("settings.edit.services.minOne"));
return;
}
const formData = new FormData();
formData.append("services", JSON.stringify(services));
- // اضافه کردن تصاویر خدمات به فرم داده
services.forEach((service) => {
if (service.image) {
const blob =
- !service.image.startsWith("/services/") &&
- dataURLtoBlob(service.image);
- if (
- typeof service.image === "string" &&
- service.image.startsWith("/services/")
- ) {
- formData.append(
- `existingServiceImages[${service.id}]`,
- service.image
- );
+ !service.image.startsWith("/services/") && dataURLtoBlob(service.image);
+ if (typeof service.image === "string" && service.image.startsWith("/services/")) {
+ formData.append(`existingServiceImages[${service.id}]`, service.image);
} else if (service.image && blob) {
- formData.append(
- `serviceImages[${service.id}]`,
- blob,
- `service-${service.id}.jpg`
- );
+ formData.append(`serviceImages[${service.id}]`, blob, `service-${service.id}.jpg`);
}
}
});
try {
await request("POST", "/verify/services", formData, {
- headers: {
- Accept: "application/json",
- "Content-Type": "multipart/form-data",
- },
+ headers: { Accept: "application/json", "Content-Type": "multipart/form-data" },
});
router.push("/settings/edit");
} catch (error) {
@@ -90,49 +76,47 @@ const ServicesPage: React.FC = () => {
};
return (
-
-
-
خدمات
-
-
-
-
- {services.map((service) => (
-
- handleDeleteService(service?._id ? service?._id : service?.id)
- }
- key={service.id || service._id}
- service={service}
+
+
+
+
+ {t("settings.edit.nav.services")}
+
+
+
+ {services.map((service) => (
+
+ handleDeleteService(service?._id ? service?._id : service?.id)
+ }
+ key={service.id || service._id}
+ service={service}
+ />
+ ))}
+
+
+
+ {t("settings.edit.save")}
+
+
setModalOpen(true)}
+ className="mb-4 !text-[#0066FF] !border-[#0066FF] w-32"
+ >
+ {t("settings.edit.add")}
+
+
+ {isModalOpen && (
+
setModalOpen(false)}
+ onAdd={handleAddService}
+ isModalOpen={isModalOpen}
/>
- ))}
+ )}
-
-
- ویرایش
-
-
setModalOpen(true)}
- className="mb-4 !text-[#0066FF] !border-[#0066FF] w-32"
- >
- افزودن
-
-
- {isModalOpen && (
- setModalOpen(false)}
- onAdd={handleAddService}
- isModalOpen={isModalOpen}
- />
- )}
-
-
+
+
);
};
diff --git a/src/app/settings/edit/shaba/page.tsx b/src/app/settings/edit/shaba/page.tsx
index c7d2c57..231d1ae 100644
--- a/src/app/settings/edit/shaba/page.tsx
+++ b/src/app/settings/edit/shaba/page.tsx
@@ -2,7 +2,8 @@
"use client";
import Container from "@/components/elements/Container";
-import React, { useEffect } from "react";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import React, { useEffect, useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
@@ -11,33 +12,34 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthInput from "@/components/auth/AuthInput";
import { useUser } from "@/hooks/useUser";
+import { useTranslation } from "react-i18next";
-// Validation schema using Yup
-const schema = yup.object().shape({
- shaba: yup
- .string()
- .matches(/^(?=.{24}$)[0-9]*$/, "شماره شبا باید ۲۴ رقم باشد")
- .required("شماره شبا الزامی است"),
-});
-
-function AuthPage() {
+function ShabaPage() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const user = useUser();
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ shaba: yup
+ .string()
+ .matches(/^(?=.{24}$)[0-9]*$/, t("settings.edit.shaba.invalid"))
+ .required(t("settings.edit.shaba.required")),
+ }),
+ [t]
+ );
+
const formik = useFormik({
- initialValues: {
- shaba: "",
- },
+ initialValues: { shaba: "" },
validationSchema: schema,
onSubmit: async (values, { setSubmitting }) => {
try {
- await request("PATCH", "/verify/shaba", {
- shaba: values.shaba,
- });
+ await request("PATCH", "/verify/shaba", { shaba: values.shaba });
router.push("/settings/edit");
} catch (err: any) {
- toast.error(err?.message || "خطای نامشخصی رخ داد.");
+ toast.error(err?.message || t("settings.edit.unknownError"));
} finally {
setSubmitting(false);
}
@@ -51,49 +53,44 @@ function AuthPage() {
}, [user?.shaba]);
return (
-
-
-
شماره شبا
-
-
-
- جهت واریز حق الزحمه
- {/* شماره شبا */}
-
-
IR
-
- {formik.touched.shaba && formik.errors.shaba && (
-
- {formik.errors.shaba}
-
- )}
-
-
-
- شماره شبا باید به نام خود شخص باشد
-
-
- ویرایش
-
-
-
-
+
+
+
+
+ {t("settings.edit.nav.shaba")}
+
+
+ {t("settings.edit.shaba.forPayout")}
+
+
IR
+
+ {formik.touched.shaba && formik.errors.shaba && (
+
+ {formik.errors.shaba}
+
+ )}
+
+ {t("settings.edit.shaba.mustBeOwn")}
+
+ {t("settings.edit.save")}
+
+
+
+
+
);
}
-export default AuthPage;
+export default ShabaPage;
diff --git a/src/app/settings/edit/sizes/page.tsx b/src/app/settings/edit/sizes/page.tsx
index 921436c..f1c053a 100644
--- a/src/app/settings/edit/sizes/page.tsx
+++ b/src/app/settings/edit/sizes/page.tsx
@@ -2,7 +2,8 @@
"use client";
import Container from "@/components/elements/Container";
-import React, { useEffect, useState } from "react";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import React, { useEffect, useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import * as yup from "yup";
import { useRouter } from "next/navigation";
@@ -10,17 +11,22 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import Image from "next/image";
import { useUser } from "@/hooks/useUser";
-
-// Validation schema using Yup
-const schema = yup.object().shape({
- size: yup.string().required("انتخاب سایز الزامی است"),
- weight: yup.string().required("وارد کردن وزن الزامی است"),
- height: yup.string().required("وارد کردن قد الزامی است"),
-});
+import { useTranslation } from "react-i18next";
const sizes = ["34", "36", "38", "40", "42", "44", "46", "48", "50"];
function Sizes() {
+ const { t } = useTranslation("common");
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ size: yup.string().required(t("settings.edit.sizes.sizeRequired")),
+ weight: yup.string().required(t("settings.edit.sizes.weightRequired")),
+ height: yup.string().required(t("settings.edit.sizes.heightRequired")),
+ }),
+ [t]
+ );
+
const [height, setHeight] = useState
("");
const [weight, setWeight] = useState("");
const [size, setSize] = useState(null);
@@ -32,7 +38,7 @@ function Sizes() {
setSize(user?.size || "");
}, [user]);
- const [selectedButton, setSelectedButton] = useState(1); // Default: 1 for "قد"
+ const [selectedButton, setSelectedButton] = useState(1);
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
@@ -43,144 +49,109 @@ function Sizes() {
await schema.validate({ height, weight, size });
await sendSizesHandler();
} catch (validationError: any) {
- toast.error(validationError.message, {
- duration: 4000,
- });
+ toast.error(validationError.message, { duration: 4000 });
}
};
const sendSizesHandler = async () => {
try {
- const data = {
- mobile,
- height,
- weight,
- size,
- };
- await request("PATCH", "/verify/sizes", data);
+ await request("PATCH", "/verify/sizes", { mobile, height, weight, size });
router.push("/settings/edit");
} catch (error: any) {
- toast.error(error?.message || "خطای نامشخصی رخ داد.");
+ toast.error(error?.message || t("settings.edit.unknownError"));
}
};
- const handleButtonPress = (buttonIndex: number) => {
- setSelectedButton(buttonIndex);
- };
-
- const handleSizeSelection = (buttonIndex: string) => {
- setSize(buttonIndex);
- };
-
return (
-
-
-
سایز
-
مشخصات ظاهری
- {/* Buttons for categories */}
-
- handleButtonPress(1)}
- >
-
- قد
-
- handleButtonPress(2)}
- >
-
- وزن
-
- handleButtonPress(3)}
- >
-
- سایز
-
-
+
+
+
+
+ {t("settings.edit.nav.sizes")}
+
+
+ {t("settings.edit.sizes.appearance")}
+
+
+ setSelectedButton(1)}
+ >
+
+ {t("settings.edit.sizes.height")}
+
+ setSelectedButton(2)}
+ >
+
+ {t("settings.edit.sizes.weight")}
+
+ setSelectedButton(3)}
+ >
+
+ {t("settings.edit.sizes.size")}
+
+
- {/* Inputs based on selected button */}
-
+
-
- ویرایش
-
-
-
+
+ {t("settings.edit.save")}
+
+
+
+
);
}
diff --git a/src/app/settings/edit/two-factor/page.tsx b/src/app/settings/edit/two-factor/page.tsx
new file mode 100644
index 0000000..f7a5767
--- /dev/null
+++ b/src/app/settings/edit/two-factor/page.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import Container from "@/components/elements/Container";
+import PageTitle from "@/components/settings/PageTitle";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import AuthOtpInput from "@/components/auth/AuthOtpInput";
+import AuthNextButton from "@/components/auth/AuthNextButton";
+import useAxios from "@/hooks/useAxios";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
+
+type TotpStatus = {
+ enabled: boolean;
+};
+
+type TotpSetup = {
+ secret: string;
+ otpauth_url: string;
+};
+
+function getApiErrorMessage(err: unknown): string | undefined {
+ const data = (
+ err as { response?: { data?: { message?: string; en_message?: string } } }
+ )?.response?.data;
+
+ if (!data) return undefined;
+
+ const generic = "خطایی در عملیات مورد نظر رخ داده است.";
+ if (data.message && data.message !== generic) return data.message;
+ if (data.en_message) return data.en_message;
+
+ return data.message;
+}
+
+export default function TwoFactorSettingsPage() {
+ const { t } = useTranslation("common");
+ const { request, loading } = useAxios();
+ const [status, setStatus] = useState(null);
+ const [setup, setSetup] = useState(null);
+ const [code, setCode] = useState("");
+ const [pageLoading, setPageLoading] = useState(true);
+
+ const qrCodeUrl = useMemo(() => {
+ if (!setup?.otpauth_url) return null;
+ return `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(
+ setup.otpauth_url
+ )}`;
+ }, [setup?.otpauth_url]);
+
+ const loadStatus = useCallback(async () => {
+ setPageLoading(true);
+ try {
+ const data = (await request("GET", "/auth/totp/status")) as TotpStatus;
+ setStatus(data);
+ } catch {
+ toast.error(t("settings.edit.twoFactor.loadError"));
+ } finally {
+ setPageLoading(false);
+ }
+ }, [request, t]);
+
+ useEffect(() => {
+ loadStatus();
+ }, [loadStatus]);
+
+ const startSetup = async () => {
+ try {
+ const data = (await request("POST", "/auth/totp/setup", {})) as TotpSetup;
+ setSetup(data);
+ setCode("");
+ } catch (err: unknown) {
+ const detail = getApiErrorMessage(err);
+ toast.error(
+ detail
+ ? `${t("settings.edit.twoFactor.setupError")} (${detail})`
+ : t("settings.edit.twoFactor.setupError")
+ );
+ }
+ };
+
+ const cancelSetup = () => {
+ setSetup(null);
+ setCode("");
+ };
+
+ const copySecret = async () => {
+ if (!setup?.secret) return;
+ try {
+ await navigator.clipboard.writeText(setup.secret);
+ toast.success(t("settings.edit.twoFactor.secretCopied"));
+ } catch {
+ toast.error(t("settings.edit.twoFactor.copyFailed"));
+ }
+ };
+
+ const enableTotp = async () => {
+ if (code.length !== 6) return;
+ try {
+ await request("POST", "/auth/totp/enable", { code });
+ toast.success(t("settings.edit.twoFactor.enableSuccess"));
+ setSetup(null);
+ setCode("");
+ await loadStatus();
+ } catch (err: unknown) {
+ const detail = getApiErrorMessage(err);
+ toast.error(detail || t("settings.edit.twoFactor.invalidCode"));
+ }
+ };
+
+ const disableTotp = async () => {
+ if (code.length !== 6) return;
+ try {
+ await request("POST", "/auth/totp/disable", { code });
+ toast.success(t("settings.edit.twoFactor.disableSuccess"));
+ setCode("");
+ await loadStatus();
+ } catch (err: unknown) {
+ const detail = getApiErrorMessage(err);
+ toast.error(detail || t("settings.edit.twoFactor.invalidCode"));
+ }
+ };
+
+ return (
+
+
+ {t("settings.edit.nav.twoFactor")}
+
+ {pageLoading ? (
+
{t("common.loading")}
+ ) : status?.enabled ? (
+ <>
+
+ {t("settings.edit.twoFactor.enabledHint")}
+
+
+
+ {t("settings.edit.twoFactor.disableButton")}
+
+ >
+ ) : setup ? (
+ <>
+
+ {t("settings.edit.twoFactor.scanHint")}
+
+ {qrCodeUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : null}
+
+ {setup.secret}
+
+
+ {t("settings.edit.twoFactor.copySecret")}
+
+
+
+ {t("settings.edit.twoFactor.enableButton")}
+
+
+ {t("settings.edit.twoFactor.cancelSetup")}
+
+ >
+ ) : (
+ <>
+
+ {t("settings.edit.twoFactor.disabledHint")}
+
+
+ {t("settings.edit.twoFactor.startSetup")}
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/app/settings/edit/username/page.tsx b/src/app/settings/edit/username/page.tsx
index 4f2126a..d3d7c86 100644
--- a/src/app/settings/edit/username/page.tsx
+++ b/src/app/settings/edit/username/page.tsx
@@ -12,8 +12,11 @@ import Container from "@/components/elements/Container";
import UsernameSuggestions from "@/components/auth/UsernameSuggestions";
import { usernameFormSchema } from "@/lib/validation/usernameSchema";
import { sanitizeUsernameInput, USERNAME_MIN_LENGTH } from "@/lib/validation/username";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { useTranslation } from "react-i18next";
function UsernamePage() {
+ const { t } = useTranslation("common");
const router = useRouter();
const user = useUser();
const { request, loading } = useAxios();
@@ -62,9 +65,10 @@ function UsernamePage() {
};
return (
+
-
+
- نام کاربری تکراری است
+ {t("settings.edit.username.duplicate")}
)}
@@ -108,11 +112,12 @@ function UsernamePage() {
className="mt-20"
loading={loading} disabled={loading || formik.values.username.length < USERNAME_MIN_LENGTH}
>
- ویرایش
+ {t("settings.edit.save")}
+
);
}
diff --git a/src/app/settings/favorites/FavoritesPageClient.tsx b/src/app/settings/favorites/FavoritesPageClient.tsx
new file mode 100644
index 0000000..bb7c3cb
--- /dev/null
+++ b/src/app/settings/favorites/FavoritesPageClient.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import Container from "@/components/elements/Container";
+import FavoritesGrid from "@/components/settings/FavoritesGrid";
+import PageTitle from "@/components/settings/PageTitle";
+import UserDetails from "@/components/settings/UserDetails";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { useTranslation } from "react-i18next";
+
+export default function FavoritesPageClient() {
+ const { t } = useTranslation("common");
+
+ return (
+
+
+ {t("settings.nav.favorites")}
+
+
+
+
+ );
+}
diff --git a/src/app/settings/favorites/page.tsx b/src/app/settings/favorites/page.tsx
index f3ee99a..19544f7 100644
--- a/src/app/settings/favorites/page.tsx
+++ b/src/app/settings/favorites/page.tsx
@@ -1,20 +1,11 @@
-import Container from "@/components/elements/Container";
-import PageTitle from "@/components/settings/PageTitle";
-import FavoritesGrid from "@/components/settings/FavoritesGrid";
-import { Metadata } from "next";
-import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import FavoritesPageClient from "./FavoritesPageClient";
+import type { Metadata } from "next";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
-export const metadata: Metadata = generatePageMetadata({
- title: "علاقمندیها | مدستاگرام",
- description: "پستهای ذخیرهشده در علاقمندیهای شما در مدستاگرام",
- path: "/settings/favorites",
-});
+export async function generateMetadata(): Promise {
+ return generateSeoPageMetadata("settingsFavorites", { index: false });
+}
export default function FavoritesPage() {
- return (
-
- علاقمندیها
-
-
- );
+ return ;
}
diff --git a/src/app/settings/financial/layout.tsx b/src/app/settings/financial/layout.tsx
index ac4867b..d33a79f 100644
--- a/src/app/settings/financial/layout.tsx
+++ b/src/app/settings/financial/layout.tsx
@@ -1,11 +1,9 @@
import type { Metadata } from "next";
-import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
-export const metadata: Metadata = generatePageMetadata({
- title: "امور مالی | مدستاگرام",
- description: "تراکنشها و امور مالی حساب کاربری در مدستاگرام",
- path: "/settings/financial",
-});
+export async function generateMetadata(): Promise {
+ return generateSeoPageMetadata("settingsFinancial", { index: false });
+}
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
diff --git a/src/app/settings/financial/page.tsx b/src/app/settings/financial/page.tsx
index 758f675..ff2482b 100644
--- a/src/app/settings/financial/page.tsx
+++ b/src/app/settings/financial/page.tsx
@@ -1,16 +1,18 @@
"use client";
import React from "react";
-
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import UserDetails from "@/components/settings/UserDetails";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
+import { useTranslation } from "react-i18next";
+import { useAppLanguage } from "@/contexts/LanguageProvider";
export interface IFinancial {
_id: string;
type: string;
- project_title?: string; // اختیاری
+ project_title?: string;
project_id?: string;
createdAt: string;
amount: number;
@@ -18,49 +20,28 @@ export interface IFinancial {
}
export default function Financial() {
-
+ const { t } = useTranslation("common");
+ const { language } = useAppLanguage();
- const {
- data,
-
- isFetchingNextPage,
-
- containerRef,
- } = useInfiniteScroll({
+ const { data, isFetchingNextPage, containerRef } = useInfiniteScroll({
endpoint: "/financial",
queryKey: ["financial"],
limit: 15,
});
- // فلت کردن تمام تراکنشها
- const allTransactions = data?.pages.flatMap((page) => page.financial || []) ?? [];
+ const allTransactions =
+ data?.pages.flatMap((page) => page.financial || []) ?? [];
-
-
-
-
- // ترجمه نوع تراکنش
const getTransactionType = (type: string) => {
- switch (type) {
- case "offer":
- return "درخواست همکاری";
- case "advertising":
- return "تبلیغات";
- default:
- return type || "سایر";
- }
+ if (type === "offer") return t("settings.financial.types.offer");
+ if (type === "advertising") return t("settings.financial.types.advertising");
+ return type || t("settings.financial.types.other");
};
- // ترجمه وضعیت
const getStatusText = (status: string) => {
- switch (status) {
- case "successful":
- return "پرداخت شده";
- case "failed":
- return "پرداخت ناموفق";
- default:
- return "در انتظار پرداخت";
- }
+ if (status === "successful") return t("settings.financial.statuses.successful");
+ if (status === "failed") return t("settings.financial.statuses.failed");
+ return t("settings.financial.statuses.pending");
};
const getStatusClass = (status: string) => {
@@ -78,37 +59,36 @@ export default function Financial() {
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
-
- const persianDate = new Intl.DateTimeFormat("fa-IR", {
+ const locale = language === "fa" ? "fa-IR" : "en-US";
+ return new Intl.DateTimeFormat(locale, {
dateStyle: "short",
timeStyle: "short",
}).format(date);
-
- return persianDate; // خروجی مثلاً: ۱۴۰۴/۱۱/۲۸، ۱۴:۳۳
} catch {
return dateStr;
}
};
+ const amountLocale = language === "fa" ? "fa-IR" : "en-US";
+
return (
-
- مالی
+
+
+ {t("settings.nav.financial")}
-
-
+
+
-
-
+
{allTransactions[0]?.docs?.map((item: IFinancial) => (
- {/* ردیف اول: عنوان + تاریخ */}
{getTransactionType(item.type)}
@@ -123,14 +103,17 @@ export default function Financial() {
- {/* ردیف دوم: مبلغ + وضعیت */}
- مبلغ پرداختی: {Number(item.amount).toLocaleString("fa-IR")} تومان
+ {t("settings.financial.amountPaid")}:{" "}
+ {Number(item.amount).toLocaleString(amountLocale)}{" "}
+ {t("settings.toman")}
- وضعیت:
+
+ {t("settings.status")}:
+
)}
-
+
-
-
+
+
);
-}
\ No newline at end of file
+}
diff --git a/src/app/settings/layout.tsx b/src/app/settings/layout.tsx
index a72f739..3aaec98 100644
--- a/src/app/settings/layout.tsx
+++ b/src/app/settings/layout.tsx
@@ -1,13 +1,10 @@
import SettingsShell from "@/components/settings/SettingsShell";
import type { Metadata } from "next";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
-export const metadata: Metadata = {
- title: {
- default: "تنظیمات | مدستاگرام",
- },
- description: "تنظیمات حساب کاربری در مدستاگرام",
- robots: { index: false, follow: false },
-};
+export async function generateMetadata(): Promise
{
+ return generateSeoPageMetadata("settings", { index: false });
+}
export default function Layout({ children }: { children: React.ReactNode }) {
return {children} ;
diff --git a/src/app/settings/loading.tsx b/src/app/settings/loading.tsx
new file mode 100644
index 0000000..1ea24cb
--- /dev/null
+++ b/src/app/settings/loading.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import IOSSpinner from "@/components/ui/IOSSpinner";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { useTranslation } from "react-i18next";
+
+export default function SettingsLoading() {
+ const { t } = useTranslation("common");
+
+ return (
+
+
+
+ {t("settings.loading")}
+
+
+ );
+}
diff --git a/src/app/settings/my-billboards/[id]/[title]/page.tsx b/src/app/settings/my-billboards/[id]/[title]/page.tsx
index 8257e4f..3f3b607 100644
--- a/src/app/settings/my-billboards/[id]/[title]/page.tsx
+++ b/src/app/settings/my-billboards/[id]/[title]/page.tsx
@@ -8,6 +8,7 @@ import BillboardImageSlider from "@/components/billboards/BillboardPage/Billboar
import MainBillboardCardActions from "@/components/billboards/MainBillboardCard/MainBillboardCardActions";
import BillboardDetails from "@/components/billboards/BillboardPage/BillboardDetails";
import MyBillboardStatusActions from "@/components/settings/my-billboards/MyBillboardStatusActions";
+import BillboardLocationLabels from "@/components/settings/my-billboards/BillboardLocationLabels";
interface IBillboardProps {
params: Promise<{ id: string; title: string }>;
@@ -55,7 +56,7 @@ async function BillboardPage({ params }: IBillboardProps) {
width={22}
height={22}
alt={"medal-star icon"}
- src={`/images/icons/medal-star.svg`}
+ src={`/images/icons/medal-star.png`}
className="pb-1"
/>
>
@@ -69,7 +70,7 @@ async function BillboardPage({ params }: IBillboardProps) {
width={22}
height={22}
alt={"star icon"}
- src={`/images/icons/star1.svg`}
+ src={`/images/icons/star1.png`}
className="pb-1"
/>
>
@@ -91,16 +92,15 @@ async function BillboardPage({ params }: IBillboardProps) {
likesCount={billboard?.likesCount}
commentsCount={billboard?.commentsCount}
viewCount={billboard?.viewCount}
- isDetail={true}
+ showViewCount
+ trackView={false}
+ />
+
-
- استان: {billboard?.province.name}
-
شهر: {billboard?.city.name}
- محله: {billboard?.neighbourhood}
-
-
-
آدرس: {billboard?.address}
-
{
+ return generateSeoPageMetadata("settingsMyBillboards", { index: false });
+}
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
diff --git a/src/app/settings/my-billboards/page.tsx b/src/app/settings/my-billboards/page.tsx
index 8407417..a18d613 100644
--- a/src/app/settings/my-billboards/page.tsx
+++ b/src/app/settings/my-billboards/page.tsx
@@ -5,6 +5,7 @@ import Container from "@/components/elements/Container";
import RoundedInput from "@/components/elements/RoundedInput";
import FilterModal from "@/components/settings/my-billboards/FilterModal";
import PageTitle from "@/components/settings/PageTitle";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { IAdvertising } from "@/types/types";
import Image from "next/image";
@@ -12,8 +13,21 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import BoldIcon from "@/components/ui/BoldIcon";
+import { useTranslation } from "react-i18next";
+
+const BILLBOARD_STATUS_KEYS = [
+ "pre_payment",
+ "temp_accept",
+ "paid",
+ "accepted",
+ "rejected",
+ "expired",
+] as const;
+
+type BillboardStatusKey = (typeof BILLBOARD_STATUS_KEYS)[number];
function MyBillboard() {
+ const { t } = useTranslation("common");
const router = useRouter();
const [searchText, setSearchText] = useState("");
@@ -26,72 +40,70 @@ function MyBillboard() {
params: { search: search, status: statusFiler },
});
- const getAdStatusAndColor = (adStatus: string) => {
- const statusMap: Record = {
- pre_payment: { label: "پرداخت نشده", color: "#aaa" },
- paid: { label: "در انتظار تایید", color: "#007bff" },
- accepted: { label: "منتشر شده", color: "#28a745" },
- rejected: { label: "رد شده", color: "#dc3545" },
- expired: { label: "منقضی شده", color: "#6c757d" },
- };
-
- return statusMap[adStatus] || { label: "نامشخص", color: "#000" };
+ const getAdStatusLabel = (adStatus: string) => {
+ if (BILLBOARD_STATUS_KEYS.includes(adStatus as BillboardStatusKey)) {
+ return t(`settings.billboards.status.${adStatus as BillboardStatusKey}`);
+ }
+ return t("settings.unknown");
};
+
const clearFilters = () => {
setStatusFiler("");
};
+
return (
-
- بیلبورد
-
-
-
- setSearchText(e.target.value)}
- placeholder="جستجو"
- className="w-full"
- />
- setSearch(searchText)}
- />
-
-
-
setShowFilterModal(true)}>
+
+
+ {t("settings.nav.billboards")}
+
+
+
+ setSearchText(e.target.value)}
+ placeholder={t("settings.billboards.search")}
+ className="w-full"
+ />
setSearch(searchText)}
/>
-
-
-
-
+
+
+ setShowFilterModal(true)}>
+
+
+
+
+
+
-
-
- {data?.pages.length === 0 ||
- (data?.pages[0]?.advertisings?.length === 0 &&
- !isFetchingNextPage) ? (
-
بیلبوردی ثبت نشده است.
- ) : (
- data?.pages?.map((page, pageIndex) => (
-
- {page?.advertisings?.map((item: IAdvertising) => {
- const { label } = getAdStatusAndColor(item?.status || "");
- return (
+
+ {data?.pages.length === 0 ||
+ (data?.pages[0]?.advertisings?.length === 0 &&
+ !isFetchingNextPage) ? (
+
+ {t("settings.billboards.empty")}
+
+ ) : (
+ data?.pages?.map((page, pageIndex) => (
+
+ {page?.advertisings?.map((item: IAdvertising) => (
@@ -102,7 +114,7 @@ function MyBillboard() {
key={item?._id}
>
- );
- })}
-
- ))
- )}
+ ))}
+
+ ))
+ )}
+
-
- {showFilterModal && (
-
- )}
-
+ {showFilterModal && (
+
+ )}
+
+
);
}
diff --git a/src/app/settings/my-billboards/profile/[id]/page.tsx b/src/app/settings/my-billboards/profile/[id]/page.tsx
index f919489..ecffa44 100644
--- a/src/app/settings/my-billboards/profile/[id]/page.tsx
+++ b/src/app/settings/my-billboards/profile/[id]/page.tsx
@@ -25,6 +25,8 @@ import AddressFields from "@/components/settings/my-billboards/Profile/AddressFi
import Image from "next/image";
import { BASE_URL } from "@/components/main/BaseUrl";
import axios from "axios";
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
// Lazy load Map and related components
const Map = dynamic(() => import("react-map-gl").then((mod) => mod.Map), {
@@ -38,26 +40,7 @@ const GeolocateControl = dynamic(
{ ssr: false }
);
-// تعریف اسکیمای اعتبارسنجی Yup
-const schema = yup.object().shape({
- markerCoordinate: yup
- .array()
- .of(yup.number())
- .length(2, "مختصات باید شامل طول و عرض جغرافیایی باشد")
- .required("انتخاب لوکیشن الزامی است"),
- address: yup.string().nullable(),
- neighbourhood: yup.string().nullable(),
- cityId: yup.string().nullable(),
- stateId: yup.string().nullable(),
- vitrine_name: yup.string().nullable(),
- categoryId: yup.string().nullable(),
- about_us: yup.string().nullable(),
- phone: yup.string().nullable(),
- mobile: yup.string().nullable(),
- telegramLink: yup.string().nullable(),
- whatsappNumber: yup.string().nullable(),
- instagramLink: yup.string().nullable(),
-});
+// تعریف اسکیمای اعتبارسنجی Yup — messages resolved via useMemo in component
interface ApiResponse {
profileDetails: IAdvertisingProfile;
@@ -68,11 +51,36 @@ interface IEditProps {
}
function EditAdProfile({ params }: IEditProps) {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const resolvedParams = React.use(params);
const { id } = resolvedParams;
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ markerCoordinate: yup
+ .array()
+ .of(yup.number())
+ .length(2, t("settings.billboards.editProfile.validation.coordinatesLength"))
+ .required(t("settings.billboards.editProfile.validation.locationRequired")),
+ address: yup.string().nullable(),
+ neighbourhood: yup.string().nullable(),
+ cityId: yup.string().nullable(),
+ stateId: yup.string().nullable(),
+ vitrine_name: yup.string().nullable(),
+ categoryId: yup.string().nullable(),
+ about_us: yup.string().nullable(),
+ phone: yup.string().nullable(),
+ mobile: yup.string().nullable(),
+ telegramLink: yup.string().nullable(),
+ whatsappNumber: yup.string().nullable(),
+ instagramLink: yup.string().nullable(),
+ }),
+ [t]
+ );
+
const [avatar, setAvatar] = useState
(null);
const [allStates, setAllStates] = useState(null);
const [cities, setCities] = useState(null);
@@ -93,9 +101,9 @@ function EditAdProfile({ params }: IEditProps) {
setCategories(response?.categories ?? []);
} catch (err) {
console.error("Error fetching categories:", err);
- toast.error("خطا در بارگذاری دستهبندیها");
+ toast.error(t("settings.billboards.editProfile.loadCategoriesError"));
}
- }, [request]);
+ }, [request, t]);
// دریافت پروفایل
const fetchProfile = useCallback(async () => {
@@ -107,9 +115,9 @@ function EditAdProfile({ params }: IEditProps) {
setProfile(response?.profileDetails ?? null);
} catch (err) {
console.error("Error fetching profile:", err);
- toast.error("خطا در بارگذاری پروفایل");
+ toast.error(t("settings.billboards.editProfile.loadProfileError"));
}
- }, [id, request]);
+ }, [id, request, t]);
// دریافت استانها
const fetchStates = useCallback(async () => {
@@ -121,9 +129,9 @@ function EditAdProfile({ params }: IEditProps) {
setAllStates(response?.provinces ?? null);
} catch (err) {
console.error("Error fetching provinces:", err);
- toast.error("خطا در بارگذاری استانها");
+ toast.error(t("settings.billboards.editProfile.loadProvincesError"));
}
- }, [request]);
+ }, [request, t]);
// دریافت شهرها
const fetchCities = useCallback(
@@ -136,10 +144,10 @@ function EditAdProfile({ params }: IEditProps) {
setCities(response?.cities ?? []);
} catch (err) {
console.error("Error fetching cities:", err);
- toast.error("خطا در بارگذاری شهرها");
+ toast.error(t("settings.billboards.editProfile.loadCitiesError"));
}
},
- [request]
+ [request, t]
);
// انتخاب و حذف تصویر
@@ -216,11 +224,14 @@ function EditAdProfile({ params }: IEditProps) {
}
);
- toast.success("پروفایل با موفقیت بهروزرسانی شد");
+ toast.success(t("settings.billboards.editProfile.profileUpdated"));
router.push("/settings");
} catch (error: any) {
console.error("Submit error:", error);
- toast.error(error?.response?.data?.message || "خطا در بهروزرسانی پروفایل");
+ toast.error(
+ error?.response?.data?.message ||
+ t("settings.billboards.editProfile.profileUpdateError")
+ );
} finally {
setSubmitting(false);
}
@@ -318,7 +329,7 @@ function EditAdProfile({ params }: IEditProps) {
return (
-
ویرایش پروفایل ویترین
+
{t("settings.billboards.editProfile.title")}
- {loading ? "در حال ارسال..." : "ویرایش"}
+ {loading ? t("settings.billboards.editProfile.submitting") : t("settings.edit.save")}
+
);
}
diff --git a/src/app/settings/notifications/layout.tsx b/src/app/settings/notifications/layout.tsx
index fdd5101..2c3f83b 100644
--- a/src/app/settings/notifications/layout.tsx
+++ b/src/app/settings/notifications/layout.tsx
@@ -1,11 +1,9 @@
import type { Metadata } from "next";
-import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
-export const metadata: Metadata = generatePageMetadata({
- title: "اعلانها | مدستاگرام",
- description: "اعلانها و پیامهای سیستمی حساب کاربری در مدستاگرام",
- path: "/settings/notifications",
-});
+export async function generateMetadata(): Promise {
+ return generateSeoPageMetadata("settingsNotifications", { index: false });
+}
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
diff --git a/src/app/settings/notifications/page.tsx b/src/app/settings/notifications/page.tsx
index 46e4839..14f7791 100644
--- a/src/app/settings/notifications/page.tsx
+++ b/src/app/settings/notifications/page.tsx
@@ -1,13 +1,21 @@
"use client";
import Container from "@/components/elements/Container";
-import RoundedInput from "@/components/elements/RoundedInput";
import PageTitle from "@/components/settings/PageTitle";
import UserDetails from "@/components/settings/UserDetails";
+import NotificationRow from "@/components/settings/NotificationRow";
+import ListDateSeparator from "@/components/settings/ListDateSeparator";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
-import Image from "next/image";
-import Link from "next/link";
-import React, { useEffect, useState } from "react";
+import { groupItemsByDate } from "@/lib/groupItemsByDate";
+import React, { useEffect, useMemo, useState } from "react";
+import BoldIcon from "@/components/ui/BoldIcon";
+import {
+ NOTIFICATION_FILTERS,
+ NotificationFilterId,
+ notificationMatchesFilter,
+} from "@/constants/notificationFilters";
+import { useTranslation } from "react-i18next";
const LIKE_NOTIFICATION_TYPES = new Set([
"post_like",
@@ -31,26 +39,12 @@ const RATING_NOTIFICATION_TYPES = new Set([
"billboard_rating",
]);
-function getNotificationTitle(item: INotification): string {
- if (LIKE_NOTIFICATION_TYPES.has(item.type)) return "پست شما لایک شد";
- if (COMMENT_NOTIFICATION_TYPES.has(item.type)) {
- return "یک کامنت برای شما ثبت شد";
- }
- if (RATING_NOTIFICATION_TYPES.has(item.type)) {
- return "یک امتیاز برای شما ثبت شد";
- }
- return item.title;
-}
-
-function getNotificationDescription(item: INotification): string {
- if (LIKE_NOTIFICATION_TYPES.has(item.type)) return "پست شما لایک شد";
- if (COMMENT_NOTIFICATION_TYPES.has(item.type)) {
- return "یک کامنت برای شما ثبت شد";
- }
- if (RATING_NOTIFICATION_TYPES.has(item.type)) {
- return "یک امتیاز برای شما ثبت شد";
- }
- return item.description;
+export interface NotificationActor {
+ _id: string;
+ user_name?: string;
+ profile_image?: string;
+ first_name?: string;
+ last_name?: string;
}
export interface INotification {
@@ -62,11 +56,15 @@ export interface INotification {
description: string;
read: boolean;
createdAt: string;
+ actor?: NotificationActor | null;
}
function Notifications() {
- const [searchText, setSearchText] = useState("");
+ const { t, i18n } = useTranslation("common");
+ const listLang = i18n.language === "en" ? "en" : "fa";
+ const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
+ const [activeFilter, setActiveFilter] = useState("all");
const [userType, setUserType] = useState("");
useEffect(() => {
@@ -74,12 +72,38 @@ function Notifications() {
setUserType(localStorage.getItem("usertype"));
}
}, []);
+
+ useEffect(() => {
+ const timer = window.setTimeout(() => setSearch(searchInput.trim()), 400);
+ return () => window.clearTimeout(timer);
+ }, [searchInput]);
+
const { data, isFetchingNextPage } = useInfiniteScroll({
endpoint: "/notifications",
queryKey: ["notifications", search],
- params: { search: search },
+ params: { search },
});
+ const getNotificationTitle = (item: INotification): string => {
+ if (item.actor?.user_name) {
+ return item.actor.user_name;
+ }
+ if (LIKE_NOTIFICATION_TYPES.has(item.type)) return t("settings.notifications.newLike");
+ if (COMMENT_NOTIFICATION_TYPES.has(item.type)) return t("settings.notifications.newComment");
+ if (RATING_NOTIFICATION_TYPES.has(item.type)) return t("settings.notifications.newRating");
+ return item.title;
+ };
+
+ const getNotificationDescription = (item: INotification): string => {
+ if (item.description && item.description !== item.title) {
+ return item.description;
+ }
+ if (LIKE_NOTIFICATION_TYPES.has(item.type)) return t("settings.notifications.likedPost");
+ if (COMMENT_NOTIFICATION_TYPES.has(item.type)) return t("settings.notifications.commented");
+ if (RATING_NOTIFICATION_TYPES.has(item.type)) return t("settings.notifications.ratedYou");
+ return item.description || item.title;
+ };
+
function getNotificationLink(item: INotification): string {
const workroomPath =
userType === "user"
@@ -115,68 +139,101 @@ function Notifications() {
academy_purchase: `/academy/${item?.project_post_id}/course`,
billboard_comment: `/settings/my-billboards/${item?.project_post_id}/b`,
billboard_rating: `/settings/my-billboards/${item?.project_post_id}/b`,
- "reject-user": `/tickets/new/${
- item?.project_post_id
- }/${encodeURIComponent(item?.title?.split("/").pop() || "")}`,
+ "reject-user": `/tickets/new/${item?.project_post_id}/${encodeURIComponent(
+ item?.title?.split("/").pop() || ""
+ )}`,
};
return routes[item?.type] || "#";
}
+
+ const filteredItems = useMemo(() => {
+ const all = data?.pages?.flatMap((page) => page?.notifications ?? []) ?? [];
+ return all.filter((item: INotification) =>
+ notificationMatchesFilter(item.type, activeFilter)
+ );
+ }, [data?.pages, activeFilter]);
+
+ const groupedItems = useMemo(
+ () =>
+ groupItemsByDate(
+ filteredItems,
+ (item) => item.createdAt,
+ (item) => item._id,
+ listLang
+ ),
+ [filteredItems, listLang]
+ );
+
return (
-
- اعلانات
-
-
-
-
setSearchText(e.target.value)}
- placeholder="جستجو"
- className="w-full"
- />
- setSearch(searchText)}
- />
+
+
+ {t("settings.nav.notifications")}
+
+
+
+
{
+ event.preventDefault();
+ setSearch(searchInput.trim());
+ }}
+ >
+
+ setSearchInput(event.target.value)}
+ placeholder={t("settings.notifications.searchPlaceholder")}
+ className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-neutral-500"
+ />
+
+
+ {NOTIFICATION_FILTERS.map((item) => (
+ setActiveFilter(item.id)}
+ className={`shrink-0 rounded-full px-3 py-1.5 text-xs font-semibold transition ${
+ activeFilter === item.id
+ ? "bg-[#FF107D] text-white"
+ : "bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-200"
+ }`}
+ >
+ {t(item.labelKey)}
+
+ ))}
+
+
+
+
+ {filteredItems.length === 0 && !isFetchingNextPage ? (
+
+ {t("settings.notifications.empty")}
+
+ ) : (
+ groupedItems.map((entry) =>
+ entry.type === "date" ? (
+
+ ) : (
+
+ )
+ )
+ )}
+
-
- {data?.pages.length === 0 ||
- (data?.pages[0]?.notifications?.length === 0 &&
- !isFetchingNextPage) ? (
-
اعلانی ثبت نشده است.
- ) : (
- data?.pages?.map((page, pageIndex) => (
-
- {page?.notifications?.map((item: INotification) => (
-
-
-
- {!item?.read ? (
-
- ) : (
- ""
- )}
-
{getNotificationTitle(item)}
-
-
{item?.createdAt}
-
- {getNotificationDescription(item)}
-
- ))}
-
- ))
- )}
-
-
-
+
+
);
}
diff --git a/src/app/settings/offers/layout.tsx b/src/app/settings/offers/layout.tsx
index bbeade2..8b4ae78 100644
--- a/src/app/settings/offers/layout.tsx
+++ b/src/app/settings/offers/layout.tsx
@@ -1,11 +1,9 @@
import type { Metadata } from "next";
-import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
-export const metadata: Metadata = generatePageMetadata({
- title: "درخواستهای همکاری | مدستاگرام",
- description: "مدیریت درخواستها و پیشنهادهای همکاری در مدستاگرام",
- path: "/settings/offers",
-});
+export async function generateMetadata(): Promise
{
+ return generateSeoPageMetadata("settingsOffers", { index: false });
+}
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
diff --git a/src/app/settings/offers/page.tsx b/src/app/settings/offers/page.tsx
index 60272c7..5ba399d 100644
--- a/src/app/settings/offers/page.tsx
+++ b/src/app/settings/offers/page.tsx
@@ -7,14 +7,20 @@ import ConfirmModal from "@/components/settings/offers/ConfirmModal";
import OffersItem from "@/components/settings/offers/OffersItem";
import PageTitle from "@/components/settings/PageTitle";
import UserDetails from "@/components/settings/UserDetails";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { IOffers } from "@/types/types";
import React, { useState } from "react";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
+import { useTranslation } from "react-i18next";
+
+const FILTER_SENT = "درخواست";
+const FILTER_RECEIVED = "دریافت";
function Offers() {
- const [filter, setFilter] = useState("درخواست");
+ const { t } = useTranslation("common");
+ const [filter, setFilter] = useState(FILTER_SENT);
const [showCommentModal, setShowCommentModal] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [itemToAction, setItemToAction] = useState(null);
@@ -27,71 +33,73 @@ function Offers() {
const actionHandler = (item: IOffers) => {
setItemToAction(item);
- if (filter == "دریافت") {
+ if (filter === FILTER_RECEIVED) {
setShowConfirmModal(true);
- } else {
- if (item?.status === "accepted") {
- setShowCommentModal(true);
- }
+ } else if (item?.status === "accepted") {
+ setShowCommentModal(true);
}
};
return (
-
- درخواست ها
-
-
-
-
setFilter("درخواست")}
- >
- درخواست
-
-
setFilter("دریافت")}
- >
- دریافت
-
+
+
+ {t("settings.nav.offers")}
+
+
+
+ setFilter(FILTER_SENT)}
+ >
+ {t("settings.offers.sent")}
+
+ setFilter(FILTER_RECEIVED)}
+ >
+ {t("settings.offers.received")}
+
+
+
+ {data?.pages.length === 0 ||
+ (data?.pages[0]?.offer?.length === 0 && !isFetchingNextPage) ? (
+
+ {t("settings.offers.empty")}
+
+ ) : (
+ data?.pages?.map((page, pageIndex) => (
+
+ {page?.offer?.map((item: IOffers) => (
+
+ ))}
+
+ ))
+ )}
+
-
- {data?.pages.length === 0 ||
- (data?.pages[0]?.offer?.length === 0 && !isFetchingNextPage) ? (
-
تراکنشی ثبت نشده است.
- ) : (
- data?.pages?.map((page, pageIndex) => (
-
- {page?.offer?.map((item: IOffers) => (
-
- ))}
-
- ))
- )}
-
-
- {showCommentModal && (
-
setShowCommentModal(false)}
- item={itemToAction}
- />
- )}
- {showConfirmModal && (
- setShowConfirmModal(false)}
- item={itemToAction}
- mode="received"
- refetch={refetch}
- />
- )}
-
+ {showCommentModal && (
+ setShowCommentModal(false)}
+ item={itemToAction}
+ />
+ )}
+ {showConfirmModal && (
+ setShowConfirmModal(false)}
+ item={itemToAction}
+ mode="received"
+ refetch={refetch}
+ />
+ )}
+
+
);
}
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index 96d4dbb..322bbcb 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -1,65 +1,11 @@
-import Container from "@/components/elements/Container";
-import ThemeSwitcher from "@/components/elements/ThemeSwitcher";
-import Logout from "@/components/settings/settings/Logout";
-import PageTitle from "@/components/settings/PageTitle";
-import Roules from "@/components/settings/settings/Roules";
-import UserDetails from "@/components/settings/UserDetails";
-import { navLinks } from "@/constants";
-import { staticIconUrl } from "@/components/main/BaseUrl";
-import Image from "next/image";
-import Link from "next/link";
-import React from "react";
+import SettingsClient from "@/components/settings/SettingsClient";
import type { Metadata } from "next";
-import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
-export const metadata: Metadata = generatePageMetadata({
- title: "تنظیمات | مدستاگرام",
- description: "تنظیمات حساب کاربری در مدستاگرام",
- path: "/settings",
-});
-
-function Settings() {
- return (
-
- تنظیمات
-
-
-
-
- {navLinks?.map((item) => {
- return (
-
-
- {item?.title}
-
- );
- })}
-
-
-
- درباره ما
-
-
-
-
-
- );
+export async function generateMetadata(): Promise {
+ return generateSeoPageMetadata("settings", { index: false });
}
-export default Settings;
+export default function Settings() {
+ return ;
+}
diff --git a/src/app/settings/profile/page.tsx b/src/app/settings/profile/page.tsx
index 68089c9..bcf96d0 100644
--- a/src/app/settings/profile/page.tsx
+++ b/src/app/settings/profile/page.tsx
@@ -4,9 +4,13 @@ import ProfileClient from "./ProfileClient";
import { BASE_URL } from "@/components/main/BaseUrl";
import { User } from "@/types/types";
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();
const token = (await cookies()).get("token")?.value || "";
+ const siteName = translateCommon(lang, "header.logoAlt");
try {
const res = await fetch(`${BASE_URL}/profile`, {
cache: "no-store",
@@ -16,9 +20,10 @@ export async function generateMetadata(): Promise {
const user = data?.user as User;
if (!user) {
return generatePageMetadata({
- title: "پروفایل | مدستاگرام",
- description: "پروفایل کاربری در مدستاگرام",
+ title: translateCommon(lang, "settings.profilePage.metaTitle"),
+ description: translateCommon(lang, "settings.profilePage.metaDescription"),
path: "/settings/profile",
+ lang,
});
}
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim();
@@ -27,7 +32,7 @@ export async function generateMetadata(): Promise {
const bio = user.bio || "";
const username = user.user_name || "";
- const title = [username, fullName, expertise, city, "مدستاگرام"]
+ const title = [username, fullName, expertise, city, siteName]
.filter(Boolean)
.join(" – ");
const description = [fullName, expertise, bio].filter(Boolean).join(" – ");
@@ -37,12 +42,14 @@ export async function generateMetadata(): Promise {
description,
path: "/settings/profile",
type: "profile",
+ lang,
});
} catch {
return generatePageMetadata({
- title: "پروفایل | مدستاگرام",
- description: "پروفایل کاربری در مدستاگرام",
+ title: translateCommon(lang, "settings.profilePage.metaTitle"),
+ description: translateCommon(lang, "settings.profilePage.metaDescription"),
path: "/settings/profile",
+ lang,
});
}
}
diff --git a/src/app/settings/profile/user-settings/layout.tsx b/src/app/settings/profile/user-settings/layout.tsx
index 6d89d7f..ca6b495 100644
--- a/src/app/settings/profile/user-settings/layout.tsx
+++ b/src/app/settings/profile/user-settings/layout.tsx
@@ -1,11 +1,9 @@
import type { Metadata } from "next";
-import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
-export const metadata: Metadata = generatePageMetadata({
- title: "تنظیمات پروفایل | مدستاگرام",
- description: "تنظیمات نمایش و حریم خصوصی پروفایل در مدستاگرام",
- path: "/settings/profile/user-settings",
-});
+export async function generateMetadata(): Promise {
+ return generateSeoPageMetadata("settingsUserSettings", { index: false });
+}
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
diff --git a/src/app/settings/profile/user-settings/page.tsx b/src/app/settings/profile/user-settings/page.tsx
index a4ab111..7b69fc4 100644
--- a/src/app/settings/profile/user-settings/page.tsx
+++ b/src/app/settings/profile/user-settings/page.tsx
@@ -3,53 +3,105 @@
import React, { useEffect, useState } from "react";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
+import LanguageSettingRow from "@/components/settings/LanguageSettingRow";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import Link from "next/link";
import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
+import { useAppLanguage } from "@/contexts/LanguageProvider";
+import { isAppLanguage } from "@/lib/i18n/constants";
+
+type PrivacyPatch = {
+ allow_save_posts?: boolean;
+ ghost_mode?: boolean;
+ show_location?: boolean;
+ post_location_enabled?: boolean;
+ is_private?: boolean;
+ preferred_language?: "fa" | "en";
+};
+
+type PrivacyResponse = {
+ allow_save_posts: boolean;
+ ghost_mode: boolean;
+ show_location: boolean;
+ post_location_enabled: boolean;
+ is_private: boolean;
+ preferred_language?: "fa" | "en";
+};
export default function UserSettingsPage() {
+ const { t } = useTranslation("common");
+ const { language, setLanguage } = useAppLanguage();
const { request } = useAxios();
const [allowSave, setAllowSave] = useState(true);
const [ghostMode, setGhostMode] = useState(false);
+ const [showLocation, setShowLocation] = useState(false);
+ const [postLocationEnabled, setPostLocationEnabled] = useState(false);
+ const [isPrivate, setIsPrivate] = useState(false);
const [loading, setLoading] = useState(true);
const [accountStatus, setAccountStatus] = useState("active");
const [reactivationLockedUntil, setReactivationLockedUntil] = useState(null);
const [reactivating, setReactivating] = useState(false);
+ const applyPrivacyState = (data: Partial) => {
+ if (typeof data.allow_save_posts === "boolean") {
+ setAllowSave(data.allow_save_posts);
+ }
+ if (typeof data.ghost_mode === "boolean") {
+ setGhostMode(data.ghost_mode);
+ }
+ if (typeof data.show_location === "boolean") {
+ setShowLocation(data.show_location);
+ }
+ if (typeof data.post_location_enabled === "boolean") {
+ setPostLocationEnabled(data.post_location_enabled);
+ }
+ if (typeof data.is_private === "boolean") {
+ setIsPrivate(data.is_private);
+ }
+ if (isAppLanguage(data.preferred_language)) {
+ void setLanguage(data.preferred_language);
+ }
+ };
+
useEffect(() => {
(async () => {
try {
const res = await request<{
- account: {
- allow_save_posts?: boolean;
- ghost_mode?: boolean;
+ account: PrivacyResponse & {
account_status?: string;
reactivation_locked_until?: string;
};
}>("GET", "/account/status", null, { noToast: true });
- setAllowSave(res?.account?.allow_save_posts !== false);
- setGhostMode(Boolean(res?.account?.ghost_mode));
+ applyPrivacyState({
+ allow_save_posts: res?.account?.allow_save_posts !== false,
+ ghost_mode: Boolean(res?.account?.ghost_mode),
+ show_location: res?.account?.show_location === true,
+ post_location_enabled: res?.account?.post_location_enabled === true,
+ is_private: res?.account?.is_private === true,
+ preferred_language: res?.account?.preferred_language,
+ });
setAccountStatus(res?.account?.account_status || "active");
setReactivationLockedUntil(res?.account?.reactivation_locked_until || null);
} finally {
setLoading(false);
}
})();
- }, [request]);
+ }, [request]); // eslint-disable-line react-hooks/exhaustive-deps
- const savePrivacy = async (patch: { allow_save_posts?: boolean; ghost_mode?: boolean }) => {
+ const savePrivacy = async (patch: PrivacyPatch) => {
try {
- const res = await request<{ allow_save_posts: boolean; ghost_mode: boolean }>(
+ const res = await request(
"PATCH",
"/account/privacy",
patch,
{ noToast: true }
);
- setAllowSave(res.allow_save_posts);
- setGhostMode(res.ghost_mode);
- toast.success("ذخیره شد");
+ applyPrivacyState(res);
+ toast.success(t("common.save"));
} catch {
- toast.error("خطا در ذخیره");
+ toast.error(t("common.saveError"));
}
};
@@ -58,9 +110,11 @@ export default function UserSettingsPage() {
try {
await request("POST", "/account/reactivate", {});
setAccountStatus("active");
- toast.success("حساب کاربری فعال شد");
+ toast.success(t("settings.reactivated"));
} catch (err) {
- toast.error(err instanceof Error ? err.message : "امکان فعالسازی نیست");
+ toast.error(
+ err instanceof Error ? err.message : t("settings.reactivateError")
+ );
} finally {
setReactivating(false);
}
@@ -71,20 +125,25 @@ export default function UserSettingsPage() {
reactivationLockedUntil &&
new Date(reactivationLockedUntil) > new Date();
+ const dateLocale = language === "fa" ? "fa-IR" : "en-US";
+
return (
-
- تنظیمات کاربر
+
+
+ {t("settings.userSettingsTitle")}
{accountStatus === "deactivated" && (
- حساب شما غیرفعال است
+ {t("settings.accountDeactivated")}
{lockActive ? (
- تا{" "}
- {new Date(reactivationLockedUntil!).toLocaleDateString("fa-IR")}{" "}
- امکان فعالسازی مجدد وجود ندارد.
+ {t("settings.reactivateLocked", {
+ date: new Date(reactivationLockedUntil!).toLocaleDateString(
+ dateLocale
+ ),
+ })}
) : (
- {reactivating ? "…" : "فعالسازی مجدد حساب"}
+ {reactivating ? "…" : t("settings.reactivate")}
)}
)}
+
+
-
بازدیدکنندگان
+
{t("settings.profileVisitors")}
›
-
ذخیره پستها
+
{t("settings.allowSavePosts")}
- اجازه ذخیره و دانلود پستهای شما برای دیگران
+ {t("settings.allowSavePostsDesc")}
-
حالت روح
+
{t("settings.postLocation")}
- پنهان کردن وضعیت آنلاین و بازدید پروفایل
+ {t("settings.postLocationDesc")}
+
+
+
+ savePrivacy({ post_location_enabled: e.target.checked })
+ }
+ className="h-5 w-5 accent-[#0095f6]"
+ />
+
+
+
+
+
{t("settings.showLocation")}
+
+ {t("settings.showLocationDesc")}
+
+
+ savePrivacy({ show_location: e.target.checked })}
+ className="h-5 w-5 accent-[#0095f6]"
+ />
+
+
+
+
+
{t("settings.privateProfile")}
+
+ {t("settings.privateProfileDesc")}
+
+
+ savePrivacy({ is_private: e.target.checked })}
+ className="h-5 w-5 accent-[#0095f6]"
+ />
+
+
+
+
+
{t("settings.ghostMode")}
+
+ {t("settings.ghostModeDesc")}
+
);
}
diff --git a/src/app/settings/profile/visitors/layout.tsx b/src/app/settings/profile/visitors/layout.tsx
index 33f3d73..59f35d3 100644
--- a/src/app/settings/profile/visitors/layout.tsx
+++ b/src/app/settings/profile/visitors/layout.tsx
@@ -1,11 +1,9 @@
import type { Metadata } from "next";
-import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
-export const metadata: Metadata = generatePageMetadata({
- title: "بازدیدکنندگان پروفایل | مدستاگرام",
- description: "لیست بازدیدکنندگان پروفایل شما در مدستاگرام",
- path: "/settings/profile/visitors",
-});
+export async function generateMetadata(): Promise {
+ return generateSeoPageMetadata("settingsProfileVisitors", { index: false });
+}
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
diff --git a/src/app/settings/profile/visitors/page.tsx b/src/app/settings/profile/visitors/page.tsx
index 7ff8089..f32ac6c 100644
--- a/src/app/settings/profile/visitors/page.tsx
+++ b/src/app/settings/profile/visitors/page.tsx
@@ -1,88 +1,81 @@
"use client";
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
+import VisitorRow, { type ProfileVisitor } from "@/components/settings/VisitorRow";
+import ListDateSeparator from "@/components/settings/ListDateSeparator";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
-import Image from "next/image";
-import Link from "next/link";
-import { buildStorageUrl } from "@/components/main/BaseUrl";
-
-interface Visitor {
- _id: string;
- user_name: string;
- first_name: string;
- last_name: string;
- profile_image?: string;
- visited_at?: string;
-}
+import { groupItemsByDate } from "@/lib/groupItemsByDate";
+import { useTranslation } from "react-i18next";
export default function ProfileVisitorsPage() {
+ const { t, i18n } = useTranslation("common");
+ const listLang = i18n.language === "en" ? "en" : "fa";
const { request } = useAxios();
- const [visitors, setVisitors] = useState([]);
+ const [visitors, setVisitors] = useState([]);
const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
useEffect(() => {
(async () => {
try {
- const res = await request<{ visitors: Visitor[] }>(
+ const res = await request<{ visitors: ProfileVisitor[] }>(
"GET",
"/account/visitors?limit=50",
null,
{ noToast: true }
);
- setVisitors(res?.visitors || []);
+ const list = (res?.visitors || []).filter((v) => v.user_name?.trim());
+ setVisitors(list);
+ } catch {
+ setError(t("settings.visitor.fetchError"));
} finally {
setLoading(false);
}
})();
- }, [request]);
+ }, [request, t]);
+
+ const groupedVisitors = useMemo(
+ () =>
+ groupItemsByDate(
+ visitors,
+ (visitor) => visitor.visited_at,
+ (visitor) => `${visitor._id}-${visitor.visited_at ?? ""}`,
+ listLang
+ ),
+ [visitors, listLang]
+ );
return (
-
- بازدیدکنندگان پروفایل
-
- {loading ? (
-
در حال بارگذاری…
- ) : visitors.length === 0 ? (
-
- هنوز بازدیدی ثبت نشده
-
- ) : (
-
- {visitors.map((v) => {
- const name =
- [v.first_name, v.last_name].filter(Boolean).join(" ") || v.user_name;
- return (
-
-
-
-
-
-
-
{name}
-
@{v.user_name}
-
-
-
- );
- })}
-
- )}
-
-
+
+
+ {t("settings.profileVisitors")}
+
+ {loading ? (
+
+ {t("common.loading")}
+
+ ) : error ? (
+
{error}
+ ) : visitors.length === 0 ? (
+
+ {t("settings.visitor.empty")}
+
+ ) : (
+
+ {groupedVisitors.map((entry) =>
+ entry.type === "date" ? (
+
+ ) : (
+
+ )
+ )}
+
+ )}
+
+
+
);
}
diff --git a/src/app/settings/tickets/layout.tsx b/src/app/settings/tickets/layout.tsx
index 9b350a1..556e4ba 100644
--- a/src/app/settings/tickets/layout.tsx
+++ b/src/app/settings/tickets/layout.tsx
@@ -1,11 +1,9 @@
import type { Metadata } from "next";
-import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
-export const metadata: Metadata = generatePageMetadata({
- title: "تیکتهای پشتیبانی | مدستاگرام",
- description: "ارسال و پیگیری تیکتهای پشتیبانی مدستاگرام",
- path: "/settings/tickets",
-});
+export async function generateMetadata(): Promise {
+ return generateSeoPageMetadata("settingsTickets", { index: false });
+}
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
diff --git a/src/app/settings/tickets/new/page.tsx b/src/app/settings/tickets/new/page.tsx
index 74e5804..9b9f7a5 100644
--- a/src/app/settings/tickets/new/page.tsx
+++ b/src/app/settings/tickets/new/page.tsx
@@ -4,17 +4,15 @@ import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import PageTitle from "@/components/settings/PageTitle";
import UserDetails from "@/components/settings/UserDetails";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import { useRouter } from "next/navigation";
import React from "react";
import { useFormik } from "formik";
import * as yup from "yup";
import useAxios from "@/hooks/useAxios";
import Container from "@/components/elements/Container";
+import { useTranslation } from "react-i18next";
-// Validation schema using Yup
-const schema = yup.object({
- title: yup.string().required("موضوع تیکت الزامی است"),
-});
interface INewTicket {
createdAt: string;
new_message: false;
@@ -25,9 +23,16 @@ interface INewTicket {
__v: number;
_id: string;
}
+
function NewTicket() {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
+
+ const schema = yup.object({
+ title: yup.string().required(t("settings.tickets.subjectRequired")),
+ });
+
const formik = useFormik({
initialValues: {
title: "",
@@ -44,37 +49,42 @@ function NewTicket() {
}
},
});
+
return (
-
- تیکت پشتیبانی
-
-
-
-
- {formik.touched.title && formik.errors.title && (
-
- {formik.errors.title}
-
- )}
- ثبت تیکت
-
-
-
+
+
+ {t("settings.tickets.title")}
+
+
+
+
+ {formik.touched.title && formik.errors.title && (
+
+ {formik.errors.title}
+
+ )}
+
+ {t("settings.tickets.create")}
+
+
+
+
+
);
}
diff --git a/src/app/settings/tickets/page.tsx b/src/app/settings/tickets/page.tsx
index bbca46f..2c76c14 100644
--- a/src/app/settings/tickets/page.tsx
+++ b/src/app/settings/tickets/page.tsx
@@ -5,13 +5,25 @@ import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import PageTitle from "@/components/settings/PageTitle";
import UserDetails from "@/components/settings/UserDetails";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { ITicket } from "@/types/types";
import Image from "next/image";
import Link from "next/link";
import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+
+function ticketStatusLabel(status: string, t: (key: string) => string) {
+ if (status === "Pending") return t("settings.tickets.statuses.pending");
+ if (status === "Answered") return t("settings.tickets.statuses.answered");
+ if (status === "Customer Response") {
+ return t("settings.tickets.statuses.customerResponse");
+ }
+ return t("settings.tickets.statuses.closed");
+}
function Tickets() {
+ const { t } = useTranslation("common");
const [searchText, setSearchText] = useState("");
const [search, setSearch] = useState("");
const { data, isFetchingNextPage } = useInfiniteScroll({
@@ -20,86 +32,83 @@ function Tickets() {
params: { search: search },
});
-
return (
-
- تیکت پشتیبانی
-
-
-
- setSearchText(e.target.value)}
- placeholder="جستجو"
- className="w-full"
- />
- setSearch(searchText)}
- />
-
-
- {data?.pages.length === 0 ||
- (data?.pages[0]?.tickets?.length === 0 && !isFetchingNextPage) ? (
-
تیکتی ثبت نشده است.
- ) : (
- data?.pages?.map((page, pageIndex) => (
-
- {page?.tickets?.map((item: ITicket) => (
-
-
-
- {item?.new_message ? (
-
- ) : (
- ""
- )}
-
{item?.title}
+
+
+ {t("settings.tickets.title")}
+
+
+
+ setSearchText(e.target.value)}
+ placeholder={t("settings.tickets.search")}
+ className="w-full"
+ />
+ setSearch(searchText)}
+ />
+
+
+ {data?.pages.length === 0 ||
+ (data?.pages[0]?.tickets?.length === 0 && !isFetchingNextPage) ? (
+
+ {t("settings.tickets.empty")}
+
+ ) : (
+ data?.pages?.map((page, pageIndex) => (
+
+ {page?.tickets?.map((item: ITicket) => (
+
+
+
+ {item?.new_message ? (
+
+ ) : (
+ ""
+ )}
+
{item?.title}
+
+
{item?.createdAt}
- {item?.createdAt}
-
-
-
- {item?.status === "Pending"
- ? "در حال بررسی"
- : item?.status === "Answered"
- ? "پاسخ داده شده"
- : item?.status === "Customer Response"
- ? "پاسخ مشتری"
- : "بسته شده"}
-
-
-
- ))}
-
- ))
- )}
+
+
+ {ticketStatusLabel(item?.status, t)}
+
+
+
+ ))}
+
+ ))
+ )}
+
+
+
+ {t("settings.tickets.create")}
+
+
-
-
- ثبت تیکت
-
-
-
-
+
+
);
}
diff --git a/src/app/settings/workroom/[id]/page.tsx b/src/app/settings/workroom/[id]/page.tsx
index 3f6f9b7..f043afd 100644
--- a/src/app/settings/workroom/[id]/page.tsx
+++ b/src/app/settings/workroom/[id]/page.tsx
@@ -9,19 +9,31 @@ import ProjectWorkroomActions from "@/components/projects/Workroom/ProjectWorkro
import ProjectRequestsAction from "@/components/projects/Workroom/ProjectRequestsAction";
import { statusMap } from "@/constants";
import ProjectWorkroomSelectedUser from "@/components/projects/Workroom/ProjectWorkroomSelectedUser";
+import { getServerLanguage } from "@/lib/i18n/server";
+import { translateCommon } from "@/lib/i18n/translate";
+import type { AppLanguage } from "@/lib/i18n/registry";
interface IProjectProps {
params: Promise<{ id: string }>;
}
-const getAdStatusAndColor = (statusType: string) =>
- statusMap[statusType as keyof typeof statusMap] ?? {
- label: "نامشخص",
- color: "#000",
+const getAdStatusAndColor = (statusType: string, lang: AppLanguage) => {
+ const entry = statusMap[statusType as keyof typeof statusMap];
+ if (!entry) {
+ return {
+ label: translateCommon(lang, "constants.unknown"),
+ color: "#000",
+ };
+ }
+ return {
+ label: translateCommon(lang, entry.labelKey),
+ color: entry.color,
};
+};
async function ProjectPage({ params }: IProjectProps) {
const { id } = await params;
+ const lang = await getServerLanguage();
const token = (await cookies()).get("token")?.value || "";
try {
@@ -33,13 +45,13 @@ async function ProjectPage({ params }: IProjectProps) {
});
if (!response.ok) {
- throw new Error(`خطا در دریافت اطلاعات: ${response.status}`);
+ throw new Error(`Fetch failed: ${response.status}`);
}
const data = await response.json();
const project = data.projectDetails as Project;
const projectRequests = data.projectRequests as IProjectRequest[];
- const { label } = getAdStatusAndColor(project?.status);
+ const { label } = getAdStatusAndColor(project?.status, lang);
let selectedUser;
if (project?.selected_user) {
selectedUser = project?.selected_user as SelectedUser;
@@ -70,7 +82,7 @@ async function ProjectPage({ params }: IProjectProps) {
)}
- کاربرانی که برای این پروژه درخواست ارسال کرده اند
+ {translateCommon(lang, "settings.workroom.projectRequestsHeading")}
- خطا در دریافت اطلاعات
+ {translateCommon(lang, "settings.workroom.fetchError")}
{error.data}
diff --git a/src/app/settings/workroom/layout.tsx b/src/app/settings/workroom/layout.tsx
index 6b1dc7f..ec1c624 100644
--- a/src/app/settings/workroom/layout.tsx
+++ b/src/app/settings/workroom/layout.tsx
@@ -1,11 +1,9 @@
import type { Metadata } from "next";
-import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import { generateSeoPageMetadata } from "@/lib/i18n/server";
-export const metadata: Metadata = generatePageMetadata({
- title: "اتاق کار | مدستاگرام",
- description: "مدیریت پروژهها و اتاق کار در مدستاگرام",
- path: "/settings/workroom",
-});
+export async function generateMetadata(): Promise {
+ return generateSeoPageMetadata("settingsWorkroom", { index: false });
+}
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
diff --git a/src/app/settings/workroom/page.tsx b/src/app/settings/workroom/page.tsx
index f2b0dad..e6b1a20 100644
--- a/src/app/settings/workroom/page.tsx
+++ b/src/app/settings/workroom/page.tsx
@@ -6,12 +6,25 @@ import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import MainProjectCard from "@/components/projects/MainProjectCard";
import PageTitle from "@/components/settings/PageTitle";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { useUser } from "@/hooks/useUser";
import { Project } from "@/types/types";
-import { statusMap } from "@/constants";
+import { useTranslation } from "react-i18next";
+
+const STATUS_OPTIONS = [
+ { key: "received", value: "دریافتی", color: "#B89FFF" },
+ { key: "unpaid", value: "پرداخت نشده", color: "#ddd" },
+ { key: "reviewing", value: "در دست بررسی", color: "#E0D891" },
+ { key: "sent", value: "ارسال شده", color: "#ADF5FF" },
+ { key: "published", value: "منتشر شده", color: "#ADF5FF" },
+ { key: "inProgress", value: "در دست اقدام", color: "#E0D891" },
+ { key: "completed", value: "اتمام پروژه", color: "#B7FFB6" },
+ { key: "cancelled", value: "کنسل شده", color: "#FFC5C5" },
+] as const;
const Workroom = () => {
+ const { t } = useTranslation("common");
const router = useRouter();
const user = useUser();
const [statusFilter, setStatusFilter] = useState("");
@@ -22,22 +35,11 @@ const Workroom = () => {
params: { status_filter: statusFilter },
});
- const getAdStatusAndColor = (statusType: string) =>
- statusMap[statusType as keyof typeof statusMap] ?? {
- label: "نامشخص",
- color: "#000",
- };
-
- const statusOptions = [
- { label: "دریافتی", value: "دریافتی", color: "#B89FFF" },
- { label: "پرداخت نشده", value: "پرداخت نشده", color: "#ddd" },
- { label: "در دست بررسی", value: "در دست بررسی", color: "#E0D891" },
- { label: "ارسال شده", value: "ارسال شده", color: "#ADF5FF" },
- { label: "منتشر شده", value: "منتشر شده", color: "#ADF5FF" },
- { label: "در دست اقدام", value: "در دست اقدام", color: "#E0D891" },
- { label: "اتمام پروژه", value: "اتمام پروژه", color: "#B7FFB6" },
- { label: "کنسل شده", value: "کنسل شده", color: "#FFC5C5" },
- ];
+ const getStatusLabel = (statusType: string) => {
+ const option = STATUS_OPTIONS.find((item) => item.value === statusType);
+ if (option) return t(`settings.workroom.statuses.${option.key}`);
+ return t("settings.unknown");
+ };
const openProject = (item: Project) => {
const creatorId =
@@ -55,57 +57,58 @@ const Workroom = () => {
};
return (
-
- پروژههای من
-
-
- {statusOptions.map(({ label, value, color }) => (
- setStatusFilter(value)}
- className={`p-1 text-xs sm:p-2 md:text-sm ${
- statusFilter === value ? " text-primary-dark" : ""
- }`}
- style={{
- backgroundColor: statusFilter === value ? color : "",
- borderColor: statusFilter === value ? color : "",
- }}
- >
- {label}
-
- ))}
-
-
- {data?.pages.length === 0 ||
- (data?.pages[0]?.projects?.length === 0 && !isFetchingNextPage) ? (
-
پروژهای ثبت نشده است.
- ) : (
- data?.pages?.map((page, pageIndex) => (
-
- {page?.projects?.map((item: Project) => {
- const { label } = getAdStatusAndColor(item?.status);
- return (
+
+
+ {t("settings.nav.workroom")}
+
+
+ {STATUS_OPTIONS.map(({ key, value, color }) => (
+ setStatusFilter(value)}
+ className={`p-1 text-xs sm:p-2 md:text-sm ${
+ statusFilter === value ? " text-primary-dark" : ""
+ }`}
+ style={{
+ backgroundColor: statusFilter === value ? color : "",
+ borderColor: statusFilter === value ? color : "",
+ }}
+ >
+ {t(`settings.workroom.statuses.${key}`)}
+
+ ))}
+
+
+ {data?.pages.length === 0 ||
+ (data?.pages[0]?.projects?.length === 0 && !isFetchingNextPage) ? (
+
+ {t("settings.workroom.empty")}
+
+ ) : (
+ data?.pages?.map((page, pageIndex) => (
+
+ {page?.projects?.map((item: Project) => (
openProject(item)}
>
- );
- })}
-
- ))
- )}
+ ))}
+
+ ))
+ )}
+
-
-
+
+
);
};
diff --git a/src/app/settings/workroom/user/[id]/page.tsx b/src/app/settings/workroom/user/[id]/page.tsx
index 21b266c..2406f77 100644
--- a/src/app/settings/workroom/user/[id]/page.tsx
+++ b/src/app/settings/workroom/user/[id]/page.tsx
@@ -8,19 +8,31 @@ import MainProjectCard from "@/components/projects/MainProjectCard";
import { statusMap } from "@/constants";
import ProjectWorkroomSelectedUser from "@/components/projects/Workroom/ProjectWorkroomSelectedUser";
import ProjectRequests from "@/components/projects/ProjectPage/ProjectRequests";
+import { getServerLanguage } from "@/lib/i18n/server";
+import { translateCommon } from "@/lib/i18n/translate";
+import type { AppLanguage } from "@/lib/i18n/registry";
interface IProjectProps {
params: Promise<{ id: string }>;
}
-const getAdStatusAndColor = (statusType: string) =>
- statusMap[statusType as keyof typeof statusMap] ?? {
- label: "نامشخص",
- color: "#000",
+const getAdStatusAndColor = (statusType: string, lang: AppLanguage) => {
+ const entry = statusMap[statusType as keyof typeof statusMap];
+ if (!entry) {
+ return {
+ label: translateCommon(lang, "constants.unknown"),
+ color: "#000",
+ };
+ }
+ return {
+ label: translateCommon(lang, entry.labelKey),
+ color: entry.color,
};
+};
async function ProjectPage({ params }: IProjectProps) {
const { id } = await params;
+ const lang = await getServerLanguage();
const token = (await cookies()).get("token")?.value || "";
try {
@@ -32,13 +44,13 @@ async function ProjectPage({ params }: IProjectProps) {
});
if (!response.ok) {
- throw new Error(`خطا در دریافت اطلاعات: ${response.status}`);
+ throw new Error(`Fetch failed: ${response.status}`);
}
const data = await response.json();
const project = data.project as Project;
const projectRequests = data.projectRequests as IProjectRequest[];
- const { label } = getAdStatusAndColor(project?.status);
+ const { label } = getAdStatusAndColor(project?.status, lang);
let selectedUser;
if (project?.selected_user) {
selectedUser = project?.selected_user as SelectedUser;
@@ -70,7 +82,7 @@ async function ProjectPage({ params }: IProjectProps) {
)}
- کاربرانی که برای این پروژه درخواست ارسال کرده اند
+ {translateCommon(lang, "settings.workroom.projectRequestsHeading")}
>
@@ -81,7 +93,7 @@ async function ProjectPage({ params }: IProjectProps) {
return (
- خطا در دریافت اطلاعات
+ {translateCommon(lang, "settings.workroom.fetchError")}
{error.data}
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index eb9d655..13cc7a6 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -15,10 +15,43 @@ async function fetchJsonSafe
(url: string): Promise {
}
}
+async function fetchAllPublicProfiles(): Promise<
+ Array<{ user_name: string; updatedAt?: string }>
+> {
+ const allProfiles: Array<{ user_name: string; updatedAt?: string }> = []
+ let page = 1
+ const limit = 500
+
+ while (page <= 20) {
+ const data = await fetchJsonSafe<{
+ users?: Array<{ user_name: string; updatedAt?: string }>
+ hasMore?: boolean
+ }>(`${API_BASE}/users/seo/public-profiles?page=${page}&limit=${limit}`)
+
+ const batch = data?.users || []
+ allProfiles.push(...batch)
+
+ if (!data?.hasMore || batch.length < limit) break
+ page += 1
+ }
+
+ return allProfiles
+}
+
export default async function sitemap(): Promise {
const baseUrl = 'https://modstagram.com'
- const staticRoutes = ['', '/videos', '/billboards', '/users', '/posts'].map((route) => ({
+ const staticRoutes = [
+ '',
+ '/videos',
+ '/billboards',
+ '/users',
+ '/posts',
+ '/projects',
+ '/academy',
+ '/explore',
+ '/about-us',
+ ].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: 'daily' as const,
@@ -62,5 +95,13 @@ export default async function sitemap(): Promise {
priority: 0.65,
}))
- return [...staticRoutes, ...billboardRoutes, ...postRoutes]
+ const publicProfiles = await fetchAllPublicProfiles()
+ const profileRoutes = publicProfiles.map((user) => ({
+ url: `${baseUrl}/users/${encodeURIComponent(user.user_name)}`,
+ lastModified: new Date(user.updatedAt || new Date()),
+ changeFrequency: 'weekly' as const,
+ priority: 0.75,
+ }))
+
+ return [...staticRoutes, ...profileRoutes, ...billboardRoutes, ...postRoutes]
}
diff --git a/src/app/users/[username]/page.tsx b/src/app/users/[username]/page.tsx
index 4f5ffb7..eb8e07e 100644
--- a/src/app/users/[username]/page.tsx
+++ b/src/app/users/[username]/page.tsx
@@ -9,6 +9,13 @@ import ProfileVisitTracker from "@/components/explore/ProfileVisitTracker";
import { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { fetchApiJson } from "@/lib/api/fetchApiJson";
+import {
+ buildProfileJsonLd,
+ buildProfileSeo,
+} from "@/lib/buildProfileSeo";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import { getServerLanguage } from "@/lib/i18n/server";
+import { translateCommon } from "@/lib/i18n/translate";
interface IUserProps {
params: Promise<{ username: string }>;
@@ -31,67 +38,63 @@ async function loadUser(username: string, token: string) {
export async function generateMetadata({ params }: IUserProps): Promise {
const { username } = await params;
const token = (await cookies()).get("token")?.value || "";
+ const lang = await getServerLanguage();
try {
const user = await loadUser(username, token);
+ const seo = buildProfileSeo(user, username, lang);
+ const profileImage = user?.profile_image
+ ? buildStorageUrl(user.profile_image)
+ : "/images/logo.png";
- if (!user) {
- return generatePageMetadata({
- title: `${username} | مدستاگرام`,
- description: `پروفایل ${username} در مدستاگرام`,
- path: `/users/${username}`,
- type: "profile",
- });
- }
-
- if (user.blocked_you) {
- return generatePageMetadata({
- title: "پروفایل در دسترس نیست | مدستاگرام",
- description: "این پروفایل در دسترس نیست.",
- path: `/users/${username}`,
- type: "profile",
- });
- }
-
- const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim();
- const city = user.city?.name || "ایران";
- const expertise = user.expertise || "متخصص";
- const bio = user.bio || "پروفایل و نمونهکارهای حرفهای در مدستاگرام";
-
- const title = [username, fullName, expertise, city, "مدستاگرام"]
- .filter(Boolean)
- .join(" – ");
- const description = [fullName, expertise, bio].filter(Boolean).join(" – ");
- const profileImage = user.profile_image ? buildStorageUrl(user.profile_image) : "/images/logo.png";
-
- return generatePageMetadata({
- title,
- description,
- path: `/users/${username}`,
+ const meta = generatePageMetadata({
+ title: seo.title,
+ ogTitle: seo.title,
+ description: seo.description,
+ path: seo.path,
type: "profile",
- imageUrl: profileImage,
- imageAlt: fullName || username,
+ imageUrl: user ? profileImage : undefined,
+ imageAlt: ("fullName" in seo ? seo.fullName : undefined) || username,
+ index: seo.index,
+ lang,
});
+
+ return {
+ ...meta,
+ title: { absolute: seo.title },
+ };
} catch {
- return generatePageMetadata({
- title: `${username} | مدستاگرام`,
- description: `پروفایل ${username} در مدستاگرام`,
- path: `/users/${username}`,
+ const seo = buildProfileSeo(null, username, lang);
+ const meta = generatePageMetadata({
+ title: seo.title,
+ ogTitle: seo.title,
+ description: seo.description,
+ path: seo.path,
type: "profile",
+ index: false,
+ lang,
});
+
+ return {
+ ...meta,
+ title: { absolute: seo.title },
+ };
}
}
async function UserPage({ params }: IUserProps) {
const { username } = await params;
const token = (await cookies()).get("token")?.value || "";
+ const lang = await getServerLanguage();
const user = await loadUser(username, token);
if (!user) {
return (
- کاربر یافت نشد!
+
+ {translateCommon(lang, "profile.userNotFound")}
+
);
}
@@ -103,64 +106,56 @@ async function UserPage({ params }: IUserProps) {
🚫
- این پروفایل در دسترس نیست
+
+ {translateCommon(lang, "profile.unavailable")}
+
- شما توسط این کاربر مسدود شدهاید و امکان مشاهده پروفایل و پستهای او وجود ندارد.
+ {translateCommon(lang, "profile.blockedByUser")}
);
}
- const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim();
+ const seo = buildProfileSeo(user, username, lang);
+ const profileImage = user.profile_image
+ ? buildStorageUrl(user.profile_image)
+ : undefined;
- // داده ساختاریافته (Schema) برای درک بهتر گوگل از ماهیت پروفایل
- const jsonLd = {
- "@context": "https://schema.org",
- "@type": "Person",
- name: fullName,
- alternateName: username,
- description: user.bio,
- image: user.profile_image ? buildStorageUrl(user.profile_image) : undefined,
- jobTitle: user.expertise,
- address: {
- "@type": "PostalAddress",
- addressLocality: user.city?.name,
- addressCountry: "IR"
- },
- url: `https://modstagram.com/users/${username}`
- };
+ const jsonLd = buildProfileJsonLd(
+ { ...user, profile_image: profileImage },
+ username,
+ seo.profileUrl || `https://modstagram.com/users/${username}`,
+ lang
+ );
return (
- {/* تزریق اسکریپت JSON-LD */}
-
- {user._id ? : null}
- {/* H1 پنهان برای تقویت کلمات کلیدی بدون تغییر ظاهر */}
-
- {fullName} - {user.expertise} در {user.city?.name}
-
+
+
+ {user._id ? : null}
+ {seo.h1}
-
-
-
+
-
-
+
+
);
}
-export default UserPage;
\ No newline at end of file
+export default UserPage;
diff --git a/src/components/BackgroundPrefetch.tsx b/src/components/BackgroundPrefetch.tsx
index cda2ed0..6bdebd8 100644
--- a/src/components/BackgroundPrefetch.tsx
+++ b/src/components/BackgroundPrefetch.tsx
@@ -4,7 +4,7 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { isAuthenticated } from "@/lib/auth/session";
-const PUBLIC_ROUTES = ["/academy", "/billboards", "/explore"];
+const PUBLIC_ROUTES = ["/", "/academy", "/billboards", "/explore", "/register"];
export default function BackgroundPrefetch() {
const router = useRouter();
@@ -13,11 +13,11 @@ export default function BackgroundPrefetch() {
const t = window.setTimeout(() => {
PUBLIC_ROUTES.forEach((route) => router.prefetch(route));
- // settings فقط وقتی لاگین است prefetch شود
if (isAuthenticated()) {
router.prefetch("/settings");
+ router.prefetch("/settings/profile");
}
- }, 2000);
+ }, 1500);
return () => window.clearTimeout(t);
}, [router]);
diff --git a/src/components/ClientErrorBoundary.tsx b/src/components/ClientErrorBoundary.tsx
new file mode 100644
index 0000000..93dbe19
--- /dev/null
+++ b/src/components/ClientErrorBoundary.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import React from "react";
+import i18n from "@/lib/i18n";
+import { readStoredLanguagePreference, resolveBrowserLanguage } from "@/lib/i18n/clientLanguage";
+
+type Props = {
+ children: React.ReactNode;
+};
+
+type State = {
+ hasError: boolean;
+};
+
+export default class ClientErrorBoundary extends React.Component {
+ state: State = { hasError: false };
+
+ static getDerivedStateFromError(): State {
+ return { hasError: true };
+ }
+
+ componentDidCatch(error: unknown) {
+ console.error("[ClientErrorBoundary]", error);
+ }
+
+ render() {
+ if (this.state.hasError) {
+ const lang = readStoredLanguagePreference() ?? resolveBrowserLanguage();
+ const t = (key: string) => i18n.t(key, { lng: lang, ns: "common" });
+
+ return (
+
+
{t("errors.pageLoad")}
+
{t("errors.safariHint")}
+
window.location.reload()}
+ className="rounded-xl bg-[#0095f6] px-5 py-2.5 text-sm font-bold text-white"
+ >
+ {t("errors.retry")}
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index a848874..b13e992 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -1,12 +1,16 @@
"use client";
+import "@/lib/i18n";
+import { LanguageProvider } from "@/contexts/LanguageProvider";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { ReactQueryProvider } from "@/providers/ReactQueryProvider";
import { Toaster } from "react-hot-toast";
import BackgroundPrefetch from "@/components/BackgroundPrefetch";
+import ScrollRestorationInit from "@/components/ScrollRestorationInit";
import TitleGuardian from "@/components/TitleGuardian";
import AuthSessionSync from "@/components/auth/AuthSessionSync";
import PwaInstallPrompt from "@/components/pwa/PwaInstallPrompt";
+import PwaHead from "@/components/PwaHead";
interface ILayoutProps {
children: React.ReactNode;
@@ -14,14 +18,18 @@ interface ILayoutProps {
function Layout({ children }: ILayoutProps) {
return (
-
+
+
+
+
{children}
-
+
+
);
}
diff --git a/src/components/NewBillboard/Step1.tsx b/src/components/NewBillboard/Step1.tsx
index f52eda0..5901fcf 100644
--- a/src/components/NewBillboard/Step1.tsx
+++ b/src/components/NewBillboard/Step1.tsx
@@ -5,35 +5,42 @@ import { useFormik } from "formik";
import * as Yup from "yup";
import LocationSelector from "./step1/LocationSelector";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
-import { useEffect, useState } from "react";
+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";
-
-const validationSchema = Yup.object({
- stateId: Yup.string().required("انتخاب استان الزامی است"),
- cityId: Yup.string().required("انتخاب شهر الزامی است"),
- categoryId: Yup.string().required("لطفا دستهبندی را انتخاب کنید"),
- adsTitle: Yup.string().required("لطفا عنوان وارد کنید"),
- description: Yup.string().required("توضیحات الزامی است"),
- neighbourhood: Yup.string().required("محله الزامی است"),
- address: Yup.string().required("آدرس الزامی است"),
- images: Yup.array()
- .min(1, "حداقل یک عکس انتخاب کنید")
- .max(5, "حداکثر ۵ عکس میتوانید انتخاب کنید")
- .required("انتخاب تصویر الزامی است"),
- markerCoordinate: Yup.array()
- .of(Yup.number().required())
- .length(2, "انتخاب موقعیت روی نقشه الزامی است")
- .required("انتخاب موقعیت روی نقشه الزامی است"),
-});
+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 {
@@ -47,8 +54,7 @@ const Step1 = ({ nextStep }: { nextStep: () => void }) => {
}
};
fetchData();
- }, []);
-console.log(categories);
+ }, [request]);
const formik = useFormik({
initialValues: {
@@ -68,14 +74,13 @@ console.log(categories);
nextStep();
},
});
- console.log(formik?.values);
return (
- ثبت بیلبورد
+ {t("billboards.createTitle")}
formik.setFieldValue("categoryId", e.target.value)}
>
- دسته بندی
+ {t("billboards.category")}
{categories?.map((item: IAdvertisingCategory) => (
-
+
{item?.title}
))}
@@ -105,7 +114,7 @@ console.log(categories);
: ""
}`}
type="text"
- placeholder="عنوان"
+ placeholder={t("billboards.title")}
{...formik.getFieldProps("adsTitle")}
/>
{formik.touched.adsTitle && formik.errors.adsTitle && (
@@ -119,7 +128,7 @@ console.log(categories);
: ""
}
border-neutral-950 text-neutral-900 dark:text-neutral-50 dark:border-neutral-400 border font-medium`}
- placeholder="توضیحات"
+ placeholder={t("billboards.description")}
{...formik.getFieldProps("description")}
/>
{formik.touched.description && formik.errors.description && (
@@ -133,7 +142,7 @@ console.log(categories);
{formik.errors.images}
)}
- ثبت و ادامه
+ {t("billboards.submitContinue")}
);
diff --git a/src/components/NewBillboard/Step2.tsx b/src/components/NewBillboard/Step2.tsx
index ef4f78a..f5a726f 100644
--- a/src/components/NewBillboard/Step2.tsx
+++ b/src/components/NewBillboard/Step2.tsx
@@ -4,19 +4,25 @@ import RoundedButton from "@/components/elements/RoundedButton";
import { useFormik } from "formik";
import * as Yup from "yup";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
-import { useState } from "react";
+import { useMemo, useState } from "react";
import { Service } from "@/types/types";
import ServiceItem from "@/components/main/Services/ServiceItem";
import AddServiceModal from "@/components/main/Services/AddServiceModal";
-
-const validationSchema = Yup.object({
- services: Yup.array().min(1, "حداقل یک خدمت یا کالا انتخاب کنید"),
-});
+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 || [],
@@ -28,49 +34,45 @@ const Step2 = ({ nextStep }: { nextStep: () => void }) => {
},
});
- // لیست سرویسها از فرمیک مقداردهی میشود
const services = formik.values.services;
const handleAddService = (newService: Service) => {
- const updatedServices = [...services, newService];
- formik.setFieldValue("services", updatedServices);
+ formik.setFieldValue("services", [...services, newService]);
};
const handleDeleteService = (id: string) => {
- const updatedServices = services.filter((service) => service.id !== id);
- formik.setFieldValue("services", updatedServices);
+ formik.setFieldValue(
+ "services",
+ services.filter((service) => service.id !== id)
+ );
};
- console.log(formik?.values);
return (
- ثبت بیلبورد
-
+
{t("billboards.createTitle")}
+
{services.map((service) => (
))}
- {/* {formik.touched.services && formik.errors.services && (
-
{formik.errors.services}
- )} */}
-
-
+
+
- ثبت و ادامه
+ {t("billboards.submitContinue")}
setModalOpen(true)}
className="!text-[#0066FF] !border-[#0066FF] w-32 h-9"
type="button"
>
- افزودن
+ {t("billboards.add")}
{isModalOpen && (
diff --git a/src/components/NewBillboard/Step3.tsx b/src/components/NewBillboard/Step3.tsx
index f34a3ce..019c80c 100644
--- a/src/components/NewBillboard/Step3.tsx
+++ b/src/components/NewBillboard/Step3.tsx
@@ -5,7 +5,8 @@ import { useFormik } from "formik";
import * as Yup from "yup";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
import useAxios from "@/hooks/useAxios";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
export interface IFeatureFetch {
title?: string;
@@ -13,18 +14,20 @@ export interface IFeatureFetch {
_id: string;
}
-const validationSchema = Yup.object({
- selectedFeatures: Yup.array().min(
- 1,
- "حداقل یکی از امکانات را باید انتخاب کنید."
- ),
-});
-
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 {
@@ -38,7 +41,7 @@ const Step3 = ({ nextStep }: { nextStep: () => void }) => {
}
};
fetchData();
- }, []);
+ }, [request]);
const formik = useFormik({
initialValues: {
@@ -49,7 +52,7 @@ const Step3 = ({ nextStep }: { nextStep: () => void }) => {
updateForm(values);
nextStep();
},
- enableReinitialize: true, // اضافه کردن این برای همگامسازی initialValues با formData
+ enableReinitialize: true,
});
const handleSelect = (
@@ -57,7 +60,6 @@ const Step3 = ({ nextStep }: { nextStep: () => void }) => {
value: boolean,
title: string | undefined
) => {
- // بهروزرسانی ویژگی انتخابشده در Formik
formik.setFieldValue("selectedFeatures", [
...formik.values.selectedFeatures.filter(
(feature) => feature._id !== featureId
@@ -71,7 +73,7 @@ const Step3 = ({ nextStep }: { nextStep: () => void }) => {
onSubmit={formik.handleSubmit}
className="flex flex-col gap-4 w-full items-center text-sm"
>
- ثبت بیلبورد
+ {t("billboards.createTitle")}
{features.map((feature: IFeatureFetch) => {
@@ -101,7 +103,7 @@ const Step3 = ({ nextStep }: { nextStep: () => void }) => {
}
className="hidden"
/>
-
دارد
+
{t("billboards.form.has")}
@@ -133,7 +135,7 @@ const Step3 = ({ nextStep }: { nextStep: () => void }) => {
}
className="hidden"
/>
-
ندارد
+
{t("billboards.form.hasNot")}
@@ -165,7 +167,7 @@ const Step3 = ({ nextStep }: { nextStep: () => void }) => {
{formik.errors.selectedFeatures}
)}
- ثبت و ادامه
+ {t("billboards.submitContinue")}
);
diff --git a/src/components/NewBillboard/Step4.tsx b/src/components/NewBillboard/Step4.tsx
index 2300ac1..8ee9f0e 100644
--- a/src/components/NewBillboard/Step4.tsx
+++ b/src/components/NewBillboard/Step4.tsx
@@ -8,6 +8,7 @@ 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(),
@@ -34,10 +35,37 @@ interface ApiResponse {
}
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 {
@@ -61,45 +89,19 @@ const Step4 = ({ nextStep }: { nextStep: () => void }) => {
};
fetchData();
- }, []);
+ }, [request]);
- 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("لطفا حداقل یک راه ارتباطی را وارد کنید.");
- } else {
- updateForm(values);
- nextStep();
- }
- },
- });
const toggleCheckBox = () => {
formik.setFieldValue("saveData", !isCheckedOne);
setIsCheckedOne(!isCheckedOne);
};
- console.log(formik?.values);
return (
- ثبت بیلبورد
+ {t("billboards.createTitle")}
void }) => {
}`}
type="text"
maxLength={11}
- placeholder="تلفن ثابت"
+ placeholder={t("billboards.form.landline")}
{...formik.getFieldProps("landline")}
/>
{formik.touched.landline && formik.errors.landline && (
@@ -123,7 +125,7 @@ const Step4 = ({ nextStep }: { nextStep: () => void }) => {
}`}
type="text"
maxLength={11}
- placeholder="شماره موبایل"
+ placeholder={t("billboards.form.mobile")}
{...formik.getFieldProps("mobile")}
/>
{formik.touched.mobile && formik.errors.mobile && (
@@ -136,7 +138,7 @@ const Step4 = ({ nextStep }: { nextStep: () => void }) => {
: "border-gray-300"
}`}
type="text"
- placeholder="آیدی تلگرام"
+ placeholder={t("billboards.form.telegram")}
{...formik.getFieldProps("telegram")}
/>
{formik.touched.telegram && formik.errors.telegram && (
@@ -149,7 +151,7 @@ const Step4 = ({ nextStep }: { nextStep: () => void }) => {
: "border-gray-300"
}`}
type="text"
- placeholder="شماره واتساپ"
+ placeholder={t("billboards.form.whatsapp")}
maxLength={11}
{...formik.getFieldProps("whatsapp")}
/>
@@ -163,7 +165,7 @@ const Step4 = ({ nextStep }: { nextStep: () => void }) => {
: "border-gray-300"
}`}
type="text"
- placeholder="آیدی اینستاگرام"
+ placeholder={t("billboards.form.instagram")}
{...formik.getFieldProps("instagram")}
/>
{formik.touched.instagram && formik.errors.instagram && (
@@ -177,12 +179,12 @@ const Step4 = ({ nextStep }: { nextStep: () => void }) => {
type="checkbox"
onChange={toggleCheckBox}
/>
- اطلاعات من را برای ویترین های بعدی ذخیره کن
+ {t("billboards.form.saveContactInfo")}
- ثبت و ادامه
+ {t("billboards.submitContinue")}
);
diff --git a/src/components/NewBillboard/Step5.tsx b/src/components/NewBillboard/Step5.tsx
index 83aa5c4..92003c5 100644
--- a/src/components/NewBillboard/Step5.tsx
+++ b/src/components/NewBillboard/Step5.tsx
@@ -4,21 +4,18 @@
import RoundedButton from "@/components/elements/RoundedButton";
import { useFormik } from "formik";
import * as Yup from "yup";
-
-import { useEffect, useState } from "react";
+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 "../MainBillboardCard/MainBillboardCard";
+import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
import { dataURLtoBlob } from "@/helpers/helpers";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
-
-const validationSchema = Yup.object({
- selectedType: Yup.string().required(" انتخاب کردن نوع پروژه الزامی است"),
-});
+import { getBillboardDisplayTypeOptionLabel } from "@/lib/billboards/displayTypeLabel";
+import { useTranslation } from "react-i18next";
interface CreateAdvertisingResponse {
success: boolean;
@@ -29,13 +26,24 @@ interface CreateAdvertisingResponse {
}
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); // New state for checkbox
+ 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,
@@ -71,6 +79,7 @@ const Step5 = () => {
console.log(err);
}
};
+
useEffect(() => {
fetchStates();
}, []);
@@ -123,14 +132,13 @@ const Step5 = () => {
}
if (showDiscount !== null && typeof showDiscount === "boolean") {
- data.append("showDiscount", String(showDiscount)); // تغییر اینجا بود!
+ data.append("showDiscount", String(showDiscount));
}
data.append("type", selectedType);
const mostDiscountPercentage =
services.length > 0
? services.reduce((max, service) => {
- // بررسی و تبدیل مقدار discountPercentage به عدد
const discount =
typeof service.discountPercentage === "number"
? service.discountPercentage
@@ -144,26 +152,15 @@ const Step5 = () => {
data.append("mostDiscountPercentage", String(mostDiscountPercentage));
}
- // const modifiedServices = services.map((service) => ({
- // ...service,
- // image: "", // جایگزین کردن مقدار image با یک رشته خالی
- // }));
-
- // data.append("services", JSON.stringify(modifiedServices));
-
- // data.append("services", JSON.stringify(services));
-
- // افزودن تصاویر به فرم دادهها
images?.forEach((image) => {
if (typeof image === "string" && image.startsWith("/advertising/")) {
data.append("existingImages[]", image);
} else {
- const blob = dataURLtoBlob(image); // تبدیل Base64 به Blob
+ const blob = dataURLtoBlob(image);
data.append("images", blob);
}
});
- // اضافه کردن تصاویر خدمات به فرم داده
services.forEach((service) => {
if (
typeof service.image === "string" &&
@@ -171,7 +168,7 @@ const Step5 = () => {
) {
data.append(`existingServiceImages[${service.id}]`, service.image);
} else if (service.image) {
- const blob = dataURLtoBlob(service.image); // تبدیل Base64 به Blob
+ const blob = dataURLtoBlob(service.image);
data.append(
`serviceImages[${service.id}]`,
blob,
@@ -195,8 +192,6 @@ const Step5 = () => {
data
);
localStorage.removeItem("billboardForm");
- // console.log(response?.data?.id);
-
router.push(`/billboards/new/${response.data.id}`);
}
} catch (err: any) {
@@ -204,6 +199,7 @@ const Step5 = () => {
}
},
});
+
useEffect(() => {
if (isEditing && formik.values.selectedType) {
formik.submitForm();
@@ -215,17 +211,15 @@ const Step5 = () => {
onSubmit={formik.handleSubmit}
className="flex flex-col gap-2 w-full items-center text-sm"
>
- نوع نمایش
- نوع نمایش بیلبورد خود برای کاربران را مشخص کنید
+ {t("billboards.displayTypeTitle")}
+ {t("billboards.displayTypeHint")}
-
- نمایش درصد تخفیف (+20،000 تومان)
-
+ {t("billboards.showDiscount")}
{typeList?.map((item: IAdvertisingType) => {
return (
@@ -250,19 +244,13 @@ const Step5 = () => {
- {item.name === "normal"
- ? "نمایش ساده"
- : item.name === "free"
- ? "نمایش ساده"
- : item.name === "special"
- ? "نمایش با آیکون ویژه"
- : "نمایش با رنگ پس زمینه متفاوت"}
- :
+ {getBillboardDisplayTypeOptionLabel(t, item.name)}:
{item.price !== 0
? !showDiscount
- ? Number(item.price).toLocaleString() + "تومان"
- : (Number(item.price) + 20000).toLocaleString() + "تومان"
- : " رایگان"}
+ ? Number(item.price).toLocaleString() + t("settings.toman")
+ : (Number(item.price) + 20000).toLocaleString() +
+ t("settings.toman")
+ : ` ${t("billboards.free")}`}
);
@@ -271,7 +259,7 @@ const Step5 = () => {
{formik.errors.selectedType}
)}
- ثبت درخواست
+ {t("billboards.submitRequest")}
);
diff --git a/src/components/NewBillboard/step1/ImageUploader.tsx b/src/components/NewBillboard/step1/ImageUploader.tsx
index 90dce75..ce978f5 100644
--- a/src/components/NewBillboard/step1/ImageUploader.tsx
+++ b/src/components/NewBillboard/step1/ImageUploader.tsx
@@ -1,12 +1,16 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
-import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
+"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) => {
@@ -14,7 +18,7 @@ const ImageUploader: React.FC = ({ formik }) => {
const files = Array.from(event.target.files);
if (images.length + files.length > 5) {
- alert("حداکثر ۵ عکس میتوانید انتخاب کنید");
+ alert(t("billboards.form.maxImagesAlert"));
return;
}
diff --git a/src/components/NewBillboard/step1/LocationSelector.tsx b/src/components/NewBillboard/step1/LocationSelector.tsx
index e9a7210..6177b05 100644
--- a/src/components/NewBillboard/step1/LocationSelector.tsx
+++ b/src/components/NewBillboard/step1/LocationSelector.tsx
@@ -1,4 +1,6 @@
/* 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";
@@ -8,12 +10,14 @@ 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();
@@ -68,7 +72,7 @@ const LocationSelector: React.FC = ({ formik }) => {
const handleProvinceChange = (e: React.ChangeEvent) => {
const selectedProvinceId = e.target.value;
formik.setFieldValue("stateId", selectedProvinceId);
- fetchCities(selectedProvinceId); // Fetch cities for the selected province
+ fetchCities(selectedProvinceId);
};
const handleMapClick = (event: any) => {
@@ -92,7 +96,7 @@ const LocationSelector: React.FC = ({ formik }) => {
onChange={handleProvinceChange}
>
- استان
+ {t("billboards.province")}
{allStates?.map((item: IProvince) => (
@@ -116,7 +120,7 @@ const LocationSelector: React.FC = ({ formik }) => {
onChange={(e) => formik.setFieldValue("cityId", e.target.value)}
>
- شهر
+ {t("billboards.city")}
{cities?.map((city: ICity) => (
@@ -136,7 +140,7 @@ const LocationSelector: React.FC = ({ formik }) => {
: ""
}`}
type="text"
- placeholder="محله "
+ placeholder={t("billboards.neighbourhood")}
{...formik.getFieldProps("neighbourhood")}
/>
@@ -144,7 +148,6 @@ const LocationSelector: React.FC = ({ formik }) => {
{formik.errors.neighbourhood}
)}
= ({ formik }) => {
: "border-border-primary-light dark:border-border-primary-dark"
}
border-neutral-950 text-neutral-900 dark:text-neutral-50 dark:border-neutral-400 font-medium`}
- placeholder="آدرس"
+ placeholder={t("billboards.address")}
{...formik.getFieldProps("address")}
>
{formik.touched.address && formik.errors.address && (
diff --git a/src/components/PwaHead.tsx b/src/components/PwaHead.tsx
new file mode 100644
index 0000000..36fc35f
--- /dev/null
+++ b/src/components/PwaHead.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { useEffect } from "react";
+import { useAppLanguage } from "@/contexts/LanguageProvider";
+import {
+ getPwaLabels,
+ PWA_VERSION,
+ toPwaLang,
+} from "@/lib/pwaConfig";
+
+function upsertLink(rel: string, href: string) {
+ let link = document.querySelector(`link[rel="${rel}"]`);
+ if (!link) {
+ link = document.createElement("link");
+ link.rel = rel;
+ document.head.appendChild(link);
+ }
+ link.href = href;
+}
+
+function upsertMeta(name: string, content: string) {
+ let meta = document.querySelector(`meta[name="${name}"]`);
+ if (!meta) {
+ meta = document.createElement("meta");
+ meta.name = name;
+ document.head.appendChild(meta);
+ }
+ meta.content = content;
+}
+
+export default function PwaHead() {
+ const { language, ready } = useAppLanguage();
+
+ useEffect(() => {
+ if (!ready) return;
+
+ const pwaLang = toPwaLang(language);
+ const labels = getPwaLabels(pwaLang);
+
+ upsertLink(
+ "manifest",
+ `/manifest.webmanifest?lang=${pwaLang}&v=${PWA_VERSION}`
+ );
+ upsertLink("apple-touch-icon", "/images/icons/apple-touch-icon.png");
+ upsertMeta("apple-mobile-web-app-title", labels.short_name);
+ upsertMeta("application-name", labels.short_name);
+ }, [language, ready]);
+
+ return null;
+}
diff --git a/src/components/RegisterSW.tsx b/src/components/RegisterSW.tsx
index 9ae627a..e20513b 100644
--- a/src/components/RegisterSW.tsx
+++ b/src/components/RegisterSW.tsx
@@ -1,17 +1,52 @@
"use client";
+
import { useEffect } from "react";
-import { ICON_VERSION } from "@/components/main/BaseUrl";
+import { PWA_VERSION } from "@/lib/pwaConfig";
+
+function isIosSafari(): boolean {
+ if (typeof navigator === "undefined") return false;
+ const ua = navigator.userAgent;
+ return /iphone|ipad|ipod/i.test(ua) && !/crios|fxios|edgios/i.test(ua);
+}
+
+async function cleanupStaleServiceWorkers() {
+ if (!("serviceWorker" in navigator)) return;
+
+ try {
+ const registrations = await navigator.serviceWorker.getRegistrations();
+ await Promise.all(
+ registrations.map((registration) => registration.unregister())
+ );
+ } catch {
+ // ignore
+ }
+}
export default function RegisterSW() {
useEffect(() => {
- if ("serviceWorker" in navigator) {
- navigator.serviceWorker
- .register(`/service-worker.js?v=${ICON_VERSION}`)
- .then(() => {
- console.log("Service Worker Registered!");
- });
+ if (!("serviceWorker" in navigator)) return;
+
+ const register = async () => {
+ try {
+ const registration = await navigator.serviceWorker.register(
+ `/service-worker.js?v=${PWA_VERSION}`,
+ { scope: "/" }
+ );
+ await registration.update();
+ } catch (error) {
+ console.error("Service Worker registration failed:", error);
+ if (isIosSafari()) {
+ await cleanupStaleServiceWorkers();
+ }
+ }
+ };
+
+ if (document.readyState === "complete") {
+ void register();
+ } else {
+ window.addEventListener("load", () => void register(), { once: true });
}
}, []);
- return null; // این کامپوننت هیچ UI نداره، فقط Service Worker رو ثبت میکنه.
+ return null;
}
diff --git a/src/components/ScrollRestorationInit.tsx b/src/components/ScrollRestorationInit.tsx
new file mode 100644
index 0000000..74c4c96
--- /dev/null
+++ b/src/components/ScrollRestorationInit.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { useEffect } from "react";
+import { initManualScrollRestoration } from "@/lib/listScrollRestoration";
+
+export default function ScrollRestorationInit() {
+ useEffect(() => {
+ initManualScrollRestoration();
+ }, []);
+
+ return null;
+}
diff --git a/src/components/TabNavigation.tsx b/src/components/TabNavigation.tsx
index c63fd50..1e408e1 100644
--- a/src/components/TabNavigation.tsx
+++ b/src/components/TabNavigation.tsx
@@ -5,13 +5,15 @@ import { useEffect, useState } from "react";
import BoldIcon from "@/components/ui/BoldIcon";
import { cn } from "@/lib/utils";
import { PAGE_SHELL_CLASS } from "@/constants/pageLayout";
+import { readLocalStorage } from "@/lib/safeStorage";
+import { useTranslation } from "react-i18next";
-const tabs = [
- { href: "/", icon: "home-2", label: "خانه" },
- { href: "/academy", icon: "teacher", label: "آموزشگاه" },
- { href: "/projects", icon: "briefcase", label: "پروژهها" },
- { href: "/billboards", icon: "flash-circle", label: "بیلبورد" },
- { href: "/settings", icon: "setting", label: "تنظیمات" },
+const TAB_KEYS = [
+ { href: "/", icon: "home-2", labelKey: "tabs.home" },
+ { href: "/academy", icon: "teacher", labelKey: "tabs.academy" },
+ { href: "/projects", icon: "briefcase", labelKey: "tabs.projects" },
+ { href: "/billboards", icon: "flash-circle", labelKey: "tabs.billboards" },
+ { href: "/settings", icon: "setting", labelKey: "tabs.settings" },
] as const;
type TabNavigationProps = {
@@ -19,6 +21,7 @@ type TabNavigationProps = {
};
export default function TabNavigation({ currentPage }: TabNavigationProps) {
+ const { t } = useTranslation("common");
const [mounted, setMounted] = useState(false);
const [isDark, setIsDark] = useState(false);
@@ -29,7 +32,7 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
useEffect(() => {
if (typeof window === "undefined") return;
const checkTheme = () => {
- const theme = localStorage.getItem("theme");
+ const theme = readLocalStorage("theme");
const isDarkMode =
theme === "dark" ||
(theme === "system" &&
@@ -51,7 +54,7 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
)}
>
- {tabs.map((tab) => {
+ {TAB_KEYS.map((tab) => {
const isActive =
currentPage === tab.href ||
(tab.href === "/settings" && currentPage.startsWith("/settings")) ||
@@ -74,17 +77,17 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
isActive
? "text-[#ff107d]"
: isDark
- ? "text-zinc-400"
+ ? "text-white"
: "text-zinc-600"
)}
/>
- {tab.label}
+ {t(tab.labelKey)}
);
diff --git a/src/components/TitleGuardian.tsx b/src/components/TitleGuardian.tsx
index e144c2a..c894265 100644
--- a/src/components/TitleGuardian.tsx
+++ b/src/components/TitleGuardian.tsx
@@ -2,16 +2,17 @@
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
-import { defaultSEOConfig } from "@/config/seoConfig";
-
-const DEFAULT_TITLE =
- typeof defaultSEOConfig.title === "string"
- ? defaultSEOConfig.title
- : "مدستاگرام";
+import { useTranslation } from "react-i18next";
export default function TitleGuardian() {
+ const { t } = useTranslation("common");
const pathname = usePathname();
- const lastValidTitle = useRef(DEFAULT_TITLE);
+ const defaultTitle = t("header.logoAlt");
+ const lastValidTitle = useRef(defaultTitle);
+
+ useEffect(() => {
+ lastValidTitle.current = defaultTitle;
+ }, [defaultTitle]);
useEffect(() => {
if (document.title.trim()) {
diff --git a/src/components/academy/AcademyFilter.tsx b/src/components/academy/AcademyFilter.tsx
index 9c67498..9aec0ac 100644
--- a/src/components/academy/AcademyFilter.tsx
+++ b/src/components/academy/AcademyFilter.tsx
@@ -1,11 +1,12 @@
"use client";
-import React, { useState, useEffect } from "react";
+import React, { useState, useEffect, useMemo } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import toast from "react-hot-toast";
import BoldIcon from "@/components/ui/BoldIcon";
import useAxios from "@/hooks/useAxios";
import { getCreateAcademyPath } from "@/lib/getCreateContentPath";
+import { useTranslation } from "react-i18next";
import AcademyFilterModal, {
AcademyFilterValues,
} from "./AcademyFilterModal";
@@ -40,20 +41,23 @@ const TOOLBAR_ICON_SIZE = 25;
const toolbarIconButtonClass =
"inline-flex h-[25px] w-[25px] items-center justify-center shrink-0 p-0 leading-none";
-const sortOptions = [
- { value: "createdAt_desc", label: "جدیدترین" },
- { value: "createdAt_asc", label: "قدیمیترین" },
- { value: "likes_desc", label: "محبوبترین" },
- { value: "price_asc", label: "ارزانترین" },
- { value: "price_desc", label: "گرانترین" },
-];
+const SORT_KEYS = [
+ "createdAt_desc",
+ "createdAt_asc",
+ "likes_desc",
+ "price_asc",
+ "price_desc",
+] as const;
-const courseTypes = [
- { value: "", label: "همه دورهها" },
- { value: "normal", label: "معمولی" },
- { value: "pro", label: "پرو" },
- { value: "free", label: "رایگان" },
-];
+const SORT_LABEL_KEYS: Record<(typeof SORT_KEYS)[number], string> = {
+ createdAt_desc: "newest",
+ createdAt_asc: "oldest",
+ likes_desc: "popular",
+ price_asc: "cheapest",
+ price_desc: "priciest",
+};
+
+const TYPE_KEYS = ["", "normal", "pro", "free"] as const;
const emptyFilters: AcademyFilterValues = {
search: "",
@@ -96,18 +100,45 @@ function buildFinalFilters(filters: AcademyFilterValues) {
}
export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
+ const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
+ const sortOptions = useMemo(
+ () =>
+ SORT_KEYS.map((value) => ({
+ value,
+ label: t(`academy.filter.sort.${SORT_LABEL_KEYS[value]}`),
+ })),
+ [t]
+ );
+
+ const courseTypes = useMemo(
+ () =>
+ TYPE_KEYS.map((value) => ({
+ value,
+ label: t(
+ value === ""
+ ? "academy.filter.types.all"
+ : `academy.filter.types.${value}`
+ ),
+ })),
+ [t]
+ );
+
const [showFilterModal, setShowFilterModal] = useState(false);
const [filters, setFilters] = useState
(emptyFilters);
- const [categories, setCategories] = useState([
- { value: "", label: "همه دستهها" },
- ]);
+ const [categories, setCategories] = useState([]);
const [categoriesLoading, setCategoriesLoading] = useState(false);
const [userType, setUserType] = useState(null);
const { request } = useAxios();
+ const allCategoriesLabel = t("academy.filter.allCategories");
+
+ useEffect(() => {
+ setCategories([{ value: "", label: allCategoriesLabel }]);
+ }, [allCategoriesLabel]);
+
useEffect(() => {
if (typeof window !== "undefined") {
setUserType(localStorage.getItem("usertype"));
@@ -134,7 +165,7 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
}
const formattedCategories: CategoryOption[] = [
- { value: "", label: "همه دستهها" },
+ { value: "", label: allCategoriesLabel },
...categoriesList.map((cat) => ({
value: cat.title,
label: cat.title,
@@ -145,14 +176,14 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
setCategories(formattedCategories);
} catch (err) {
- console.log("خطا در دریافت دستهبندی آکادمی:", err);
+ console.log("Academy categories fetch error:", err);
} finally {
setCategoriesLoading(false);
}
};
fetchAcademyCategories();
- }, []);
+ }, [allCategoriesLabel, request]);
useEffect(() => {
setFilters({
@@ -199,7 +230,7 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
const handleCreateAcademy = () => {
if (!userType) {
- toast.error("لطفا ابتدا در سایت وارد شوید یا ثبت نام کنید.");
+ toast.error(t("academy.loginRequired"));
return;
}
router.push(getCreateAcademyPath());
@@ -215,7 +246,7 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
router.push("/search?type=user")}
- aria-label="جستجو"
+ aria-label={t("academy.searchAria")}
className={toolbarIconButtonClass}
>
setShowFilterModal(true)}
- aria-label="فیلتر"
+ aria-label={t("academy.filterAria")}
className={toolbarIconButtonClass}
>
diff --git a/src/components/academy/AcademyFilterModal.tsx b/src/components/academy/AcademyFilterModal.tsx
index 292ab2b..f7950bc 100644
--- a/src/components/academy/AcademyFilterModal.tsx
+++ b/src/components/academy/AcademyFilterModal.tsx
@@ -2,6 +2,7 @@
import React from "react";
import { X } from "lucide-react";
+import { useTranslation } from "react-i18next";
import Modal from "../elements/Modal";
import RoundedButton from "../elements/RoundedButton";
import { cn } from "@/lib/utils";
@@ -50,6 +51,7 @@ export default function AcademyFilterModal({
onClear,
activeFiltersCount,
}: AcademyFilterModalProps) {
+ const { t } = useTranslation("common");
const selectedCategory = categories.find((cat) => cat.value === filters.category);
const handleFieldChange = (name: keyof AcademyFilterValues, value: string | boolean) => {
@@ -64,11 +66,11 @@ export default function AcademyFilterModal({
panelClassName="max-h-[min(88vh,640px)] overflow-y-auto rounded-t-3xl sm:rounded-3xl"
>
-
فیلتر دورهها
+
{t("academy.filter.title")}
-
دسته بندی
+
{t("academy.filter.category")}
handleFieldChange("category", e.target.value)}
@@ -77,7 +79,7 @@ export default function AcademyFilterModal({
>
{categoriesLoading ? (
- در حال بارگذاری...
+ {t("common.loading")}
) : (
categories.map((cat) => (
@@ -90,7 +92,7 @@ export default function AcademyFilterModal({
{selectedCategory && selectedCategory.value !== "" && (
- انتخاب شده:
+ {t("academy.filter.selected")}
{selectedCategory.icon && {selectedCategory.icon} }
{selectedCategory.label}
@@ -100,7 +102,7 @@ export default function AcademyFilterModal({
-
نوع دوره
+
{t("academy.filter.courseType")}
handleFieldChange("type", e.target.value)}
@@ -116,19 +118,19 @@ export default function AcademyFilterModal({
@@ -183,14 +185,14 @@ export default function AcademyFilterModal({
onClick={onApply}
className="h-10 w-full max-w-[200px] text-sm"
>
- اعمال فیلتر
+ {t("academy.filter.apply")}
setShowFilterModal(false)}
className={cn("h-10 w-full max-w-[200px] text-sm")}
>
- انصراف
+ {t("academy.filter.cancel")}
diff --git a/src/components/academy/AcademyPackageItem.tsx b/src/components/academy/AcademyPackageItem.tsx
index 1788743..50ebb07 100644
--- a/src/components/academy/AcademyPackageItem.tsx
+++ b/src/components/academy/AcademyPackageItem.tsx
@@ -6,12 +6,15 @@ import { formatCoursePriceLabel } from "@/lib/formatCoursePriceLabel";
import Image from "next/image";
import Link from "next/link";
import React from "react";
+import { useTranslation } from "react-i18next";
function AcademyPackageItem({
course,
}: {
course: Course;
}) {
+ const { i18n } = useTranslation("common");
+ const priceLang = i18n.language === "en" ? "en" : "fa";
const imageSrc = course.course_image
? `${IMAGE_BASE_URL}${course.course_image}`
: "/images/default-course.jpg";
@@ -47,7 +50,7 @@ function AcademyPackageItem({
course.is_free ? "text-[#008D0E]" : "text-[#3A59A9]"
}
>
- {formatCoursePriceLabel(course)}
+ {formatCoursePriceLabel(course, priceLang)}
diff --git a/src/components/academy/CommentsModal.tsx b/src/components/academy/CommentsModal.tsx
index a2f5b5c..f37e544 100644
--- a/src/components/academy/CommentsModal.tsx
+++ b/src/components/academy/CommentsModal.tsx
@@ -1,35 +1,9 @@
-import React, { useState, useRef, useCallback, useEffect } from "react";
-import Modal from "../elements/Modal";
-import Image from "next/image";
-import VerificationBadge from "@/components/main/VerificationBadge";
-import ProfileAvatar from "@/components/main/ProfileAvatar";
-import RoundedInput from "@/components/elements/RoundedInput";
-import useAxios from "@/hooks/useAxios";
-import toast from "react-hot-toast";
-import Rate from "rc-rate";
-import "rc-rate/assets/index.css";
-import "@/styles/rc-rate-custom.css";
-import { useUser } from "@/hooks/useUser";
-import { Course } from "@/types/types";
-import { useRequireAuth } from "@/lib/auth/useRequireAuth";
+"use client";
-export interface Comments {
- _id: string;
- comment: string;
- rate: number;
- createdAt: string;
- updatedAt: string;
- status: string;
- user_id: {
- _id: string;
- user_name: string;
- profile_image: string;
- is_verified: string;
- };
- course_id: string;
- likes: number;
- isEdited: boolean;
-}
+import React from "react";
+import Modal from "@/components/elements/Modal";
+import CourseCommentsContent from "./CourseCommentsContent";
+import { Course } from "@/types/types";
interface CommentsModalProps {
isOpen: boolean;
@@ -44,457 +18,20 @@ function CommentsModal({
postData,
onCommentAdded,
}: CommentsModalProps) {
- const { _id, cuorse_name } = postData;
-
- const [newMessage, setNewMessage] = useState("");
- const [rating, setRating] = useState(0);
- const [comments, setComments] = useState([]);
- const [totalComments, setTotalComments] = useState(0);
- const [averageRate, setAverageRate] = useState(0);
- const [loading, setLoading] = useState(false);
- const [page, setPage] = useState(1);
- const [hasMore, setHasMore] = useState(true);
- const [statusFilter, setStatusFilter] = useState("accepted");
- const [sortBy, setSortBy] = useState("createdAt");
- const [sortOrder, setSortOrder] = useState("desc");
- const [usersCache, setUsersCache] = useState>({});
-
- const { request } = useAxios();
- const containerRef = useRef(null);
- const currentUser = useUser();
- const requireAuth = useRequireAuth();
- const [hasRatedCourse, setHasRatedCourse] = useState(false);
-
- // تابع دریافت اطلاعات کاربر با caching
- const fetchUserInfo = async (userId: string) => {
- if (usersCache[userId]) {
- return usersCache[userId];
- }
-
- try {
- const response = await request("GET", `/profile/${userId}`);
- if (response?.user) {
- const userData = response.user;
- setUsersCache((prev) => ({ ...prev, [userId]: userData }));
- return userData;
- }
- } catch (error) {
- console.error(`Error fetching user ${userId}:`, error);
- }
- return null;
- };
-
- // دریافت تعداد کل کامنتها و آمار
- const fetchCommentsStats = async () => {
- if (!_id) return;
-
- try {
- const response = await request(
- "GET",
- `/academy/course/${_id}/comments/count`
- );
- if (response?.success) {
- setTotalComments(response.data.totalComments);
- setAverageRate(response.data.averageRate || 0);
- }
- } catch (error) {
- console.error("Error fetching stats:", error);
- }
- };
-
- // دریافت لیست کامنتها
- const fetchComments = async (pageNum: number, append = false) => {
- if (loading || !_id) return;
-
- setLoading(true);
- try {
- const response = await request(
- "GET",
- `/academy/course/${_id}/comments?page=${pageNum}&limit=20&status=${statusFilter}&sortBy=${sortBy}&sortOrder=${sortOrder}`
- );
-
- if (response?.success) {
- let newComments = response.data.comments;
- const pagination = response.data.pagination;
-
- if (newComments && newComments.length > 0) {
- // بررسی اینکه آیا بکاند اطلاعات کاربر را populate کرده است
- if (newComments[0]?.user_id?.user_name) {
- // اطلاعات کاربر قبلاً داخل کامنت است
- setComments((prev) =>
- append ? [...prev, ...newComments] : newComments
- );
- } else {
- // اگر بکاند فقط user_id را برگردانده، اطلاعات کاربر را بگیریم
- const uniqueUserIds = [...new Set(newComments.map((c) => c.user_id))];
-
- // دریافت اطلاعات همه کاربران به صورت همزمان
- const usersData = await Promise.all(
- uniqueUserIds.map((id) => fetchUserInfo(id))
- );
-
- // ساخت mapping از userId به اطلاعات کاربر
- const userMap: Record = {};
- uniqueUserIds.forEach((id, index) => {
- userMap[id] = usersData[index];
- });
-
- // ترکیب کامنتها با اطلاعات کاربران
- const commentsWithUsers = newComments.map((comment) => ({
- ...comment,
- user_id: userMap[comment.user_id] || {
- _id: comment.user_id,
- user_name: "کاربر ناشناس",
- profile_image: null,
- is_verified: "unverified"
- },
- }));
-
- setComments((prev) =>
- append ? [...prev, ...commentsWithUsers] : commentsWithUsers
- );
- }
- } else {
- setComments(append ? [...prev] : []);
- }
-
- setHasMore(pagination?.hasNextPage || false);
- if (pagination?.totalItems) {
- setTotalComments(pagination.totalItems);
- }
- }
- } catch (error) {
- console.error("Error fetching comments:", error);
- toast.error("خطا در دریافت نظرات");
- } finally {
- setLoading(false);
- }
- };
-
- // لود اولیه و هنگام تغییر فیلترها
- useEffect(() => {
- if (isOpen && _id) {
- setPage(1);
- setComments([]);
- fetchComments(1, false);
- fetchCommentsStats();
- }
- }, [isOpen, _id, statusFilter, sortBy, sortOrder]);
-
- // اینفینیت اسکرول
- const observer = useRef(null);
- const lastCommentRef = useCallback(
- (node: HTMLDivElement) => {
- if (loading) return;
- if (observer.current) observer.current.disconnect();
- observer.current = new IntersectionObserver((entries) => {
- if (entries[0].isIntersecting && hasMore) {
- setPage((prev) => prev + 1);
- }
- });
- if (node) observer.current.observe(node);
- },
- [loading, hasMore]
- );
-
- useEffect(() => {
- if (page > 1) {
- fetchComments(page, true);
- }
- }, [page]);
-
- useEffect(() => {
- if (!currentUser?._id) return;
- const alreadyRated = comments.some((item) => {
- const commentUserId =
- typeof item.user_id === "string" ? item.user_id : item.user_id?._id;
- return (
- String(commentUserId) === String(currentUser._id) &&
- Number(item.rate) > 0
- );
- });
- setHasRatedCourse(alreadyRated);
- }, [comments, currentUser?._id]);
-
- // ارسال کامنت جدید
- const sendMessageHandler = async () => {
- if (!requireAuth()) return;
- if (!newMessage.trim()) {
- toast.error("ثبت نظر الزامی است.");
- return;
- }
- if (newMessage.length > 500) {
- toast.error("کامنت نمیتواند بیش از 500 کاراکتر باشد.");
- return;
- }
-
- try {
- const payload: Record = {
- course_id: _id,
- comment: newMessage,
- };
- if (!hasRatedCourse && rating > 0) {
- payload.rate = rating;
- }
-
- const response = await request("POST", "/academy/comment/create", payload);
-
- if (response?.success) {
- toast.success("نظر شما با موفقیت ثبت شد");
- setNewMessage("");
- setRating(0);
- if (!hasRatedCourse && rating > 0) {
- setHasRatedCourse(true);
- }
-
- // ریست کردن صفحه و دریافت مجدد کامنتها
- setPage(1);
- setComments([]);
- await fetchComments(1, false);
- await fetchCommentsStats();
-
- if (onCommentAdded) {
- onCommentAdded();
- }
- }
- } catch (error: any) {
- console.error("Error sending comment:", error);
- toast.error(error?.response?.data?.message || "خطا در ثبت نظر");
- }
- };
-
- // تغییر وضعیت کامنت
- const handleStatusChange = async (commentId: string, newStatus: string) => {
- try {
- const response = await request(
- "PATCH",
- `/academy/comment/${commentId}/status`,
- {
- status: newStatus,
- }
- );
-
- if (response?.success) {
- toast.success(response.message);
- setPage(1);
- setComments([]);
- await fetchComments(1, false);
- }
- } catch (error: any) {
- console.error("Error changing status:", error);
- toast.error(error?.response?.data?.message || "خطا در تغییر وضعیت");
- }
- };
-
- // حذف کامنت
- const handleDeleteComment = async (commentId: string) => {
- if (!confirm("آیا از حذف این کامنت مطمئن هستید؟")) return;
-
- try {
- const response = await request(
- "DELETE",
- `/academy/comment/${commentId}/delete`
- );
-
- if (response?.success) {
- toast.success("کامنت با موفقیت حذف شد");
- setPage(1);
- setComments([]);
- await fetchComments(1, false);
- await fetchCommentsStats();
- }
- } catch (error: any) {
- console.error("Error deleting comment:", error);
- toast.error(error?.response?.data?.message || "خطا در حذف کامنت");
- }
- };
-
- // دریافت وضعیت نمایشی
- const getStatusBadge = (status: string) => {
- switch (status) {
- case "accepted":
- return (
-
- تایید شده
-
- );
- case "pending":
- return (
-
- در انتظار تایید
-
- );
- case "rejected":
- return (
-
- رد شده
-
- );
- default:
- return null;
- }
- };
-
- // کامپوننت نمایش ستارهها
- const renderStars = (rate: number) => {
- return (
-
- {[1, 2, 3, 4, 5].map((star) => (
-
-
-
- ))}
-
- );
- };
-
return (
-
-
- {/* هدر مودال */}
-
-
{cuorse_name}
-
- 📝 {totalComments} نظر
- {averageRate > 0 && ⭐ {averageRate.toFixed(1)} / 5 }
-
-
-
- {/* بخش لیست کامنتها */}
-
- {!loading && comments.length === 0 ? (
-
- هنوز نظری ثبت نشده است. اولین نفری باشید که نظر میدهید!
-
- ) : (
- comments.map((item, index) => (
-
-
- {/* آواتار کاربر */}
-
-
-
- {/* اطلاعات کاربر و امتیاز */}
-
-
- {item?.user_id?.user_name || "کاربر ناشناس"}
-
-
-
- {item?.rate > 0 && (
-
- {renderStars(item.rate)}
-
- )}
-
- {/* وضعیت کامنت (اختیاری) */}
- {item.status && item.status !== "accepted" && (
-
- {getStatusBadge(item.status)}
-
- )}
-
-
- {/* متن کامنت */}
-
- {item.comment}
-
-
- {/* متادیتا */}
-
-
- {new Date(item.createdAt).toLocaleDateString("fa-IR")}
-
-
- ❤️ {item.likes || 0}
-
- {item.isEdited && (
- (ویرایش شده)
- )}
-
-
-
-
- ))
- )}
-
- {loading && (
-
-
-
- در حال بارگذاری نظرات...
-
-
- )}
-
-
- {/* بخش ارسال نظر */}
-
-
- {!hasRatedCourse && (
-
setRating(value)}
- count={5}
- style={{ fontSize: "28px" }}
- />
- )}
-
-
-
-
- {
- if (e.target.value.length <= 500)
- setNewMessage(e.target.value);
- }}
- placeholder="نظر خود را بنویسید..."
- className="flex-1"
- />
-
-
- {newMessage.length}/500 کاراکتر
- {rating > 0 && امتیاز: {rating} از 5 }
-
-
-
-
+
+
);
}
-export default CommentsModal;
\ No newline at end of file
+export default CommentsModal;
+
+export type { CourseComment as Comments } from "./CourseCommentsContent";
diff --git a/src/components/academy/CourseCommentsContent.tsx b/src/components/academy/CourseCommentsContent.tsx
new file mode 100644
index 0000000..055b7d5
--- /dev/null
+++ b/src/components/academy/CourseCommentsContent.tsx
@@ -0,0 +1,367 @@
+"use client";
+
+import React, {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import RoundedInput from "@/components/elements/RoundedInput";
+import useAxios from "@/hooks/useAxios";
+import toast from "react-hot-toast";
+import Rate from "rc-rate";
+import "rc-rate/assets/index.css";
+import "@/styles/rc-rate-custom.css";
+import BoldIcon from "@/components/ui/BoldIcon";
+import ProfileAvatar from "@/components/main/ProfileAvatar";
+import { useRequireAuth } from "@/lib/auth/useRequireAuth";
+import { useUser } from "@/hooks/useUser";
+import { useTranslation } from "react-i18next";
+import { cn } from "@/lib/utils";
+import CaptionWithMentions from "@/components/posts/CaptionWithMentions";
+
+export interface CourseCommentUser {
+ _id: string;
+ user_name: string;
+ profile_image: string | null;
+ is_verified: string;
+}
+
+export interface CourseComment {
+ _id: string;
+ comment: string;
+ rate: number;
+ createdAt: string;
+ updatedAt?: string;
+ status: string;
+ user_id: CourseCommentUser | string;
+ course_id: string;
+ likes?: number;
+ isEdited?: boolean;
+}
+
+type CourseCommentsContentProps = {
+ courseId: string;
+ courseName?: string;
+ variant?: "modal" | "sheet";
+ onClose?: () => void;
+ onCommentAdded?: () => void;
+ className?: string;
+};
+
+function normalizeUser(
+ userId: CourseCommentUser | string,
+ cache: Record,
+ anonymousLabel: string
+): CourseCommentUser {
+ if (typeof userId === "object" && userId?.user_name) {
+ return userId;
+ }
+ const id = typeof userId === "string" ? userId : userId?._id;
+ return (
+ cache[id] || {
+ _id: id,
+ user_name: anonymousLabel,
+ profile_image: null,
+ is_verified: "unverified",
+ }
+ );
+}
+
+function CommentRow({
+ item,
+ anonymousLabel,
+ userLabel,
+}: {
+ item: CourseComment & { user: CourseCommentUser };
+ anonymousLabel: string;
+ userLabel: string;
+}) {
+ return (
+
+
+
+
+ {item.user?.user_name}
+ {item.rate > 0 ? (
+ ★ {item.rate}
+ ) : null}
+
+
+
+
+
+
+ {new Date(item.createdAt).toLocaleDateString("fa-IR")}
+
+
+
+
+ );
+}
+
+export default function CourseCommentsContent({
+ courseId,
+ courseName,
+ variant = "modal",
+ onClose,
+ onCommentAdded,
+ className,
+}: CourseCommentsContentProps) {
+ const { t } = useTranslation("common");
+ const [newMessage, setNewMessage] = useState("");
+ const [rating, setRating] = useState(0);
+ const [comments, setComments] = useState<
+ Array
+ >([]);
+ const [loading, setLoading] = useState(false);
+ const [page, setPage] = useState(1);
+ const [hasMore, setHasMore] = useState(true);
+ const [hasRatedCourse, setHasRatedCourse] = useState(false);
+ const [usersCache, setUsersCache] = useState>(
+ {}
+ );
+ const usersCacheRef = useRef(usersCache);
+ usersCacheRef.current = usersCache;
+
+ const { request } = useAxios();
+ const requireAuth = useRequireAuth();
+ const currentUser = useUser();
+ const containerRef = useRef(null);
+ const observer = useRef(null);
+
+ const fetchUserInfo = async (userId: string) => {
+ if (usersCacheRef.current[userId]) return usersCacheRef.current[userId];
+ try {
+ const response = await request("GET", `/profile/${userId}`);
+ if (response?.user) {
+ const userData: CourseCommentUser = {
+ _id: response.user._id,
+ user_name: response.user.user_name,
+ profile_image: response.user.profile_image,
+ is_verified: response.user.is_verified,
+ };
+ setUsersCache((prev) => ({ ...prev, [userId]: userData }));
+ return userData;
+ }
+ } catch (error) {
+ console.error(`Error fetching user ${userId}:`, error);
+ }
+ return null;
+ };
+
+ const fetchComments = useCallback(
+ async (pageNum: number, append = false) => {
+ if (!courseId) return;
+
+ setLoading(true);
+ try {
+ const response = await request(
+ "GET",
+ `/academy/course/${courseId}/comments?page=${pageNum}&limit=20&status=accepted&sortBy=createdAt&sortOrder=desc`
+ );
+
+ if (response?.success) {
+ const newComments: CourseComment[] = response.data.comments || [];
+ const pagination = response.data.pagination;
+
+ const enriched = await Promise.all(
+ newComments.map(async (comment) => {
+ let user: CourseCommentUser;
+ if (
+ typeof comment.user_id === "object" &&
+ comment.user_id?.user_name
+ ) {
+ user = comment.user_id;
+ } else {
+ const uid =
+ typeof comment.user_id === "string"
+ ? comment.user_id
+ : comment.user_id?._id;
+ const fetched = await fetchUserInfo(uid);
+ user =
+ fetched ||
+ normalizeUser(comment.user_id, usersCacheRef.current, t("common.user"));
+ }
+ return { ...comment, user };
+ })
+ );
+
+ setComments((prev) => (append ? [...prev, ...enriched] : enriched));
+ setHasMore(pagination?.hasNextPage || false);
+ }
+ } catch (error) {
+ console.error("Error fetching comments:", error);
+ toast.error(t("academy.commentsError"));
+ } finally {
+ setLoading(false);
+ }
+ },
+ [courseId, request]
+ );
+
+ useEffect(() => {
+ setPage(1);
+ setComments([]);
+ void fetchComments(1, false);
+ }, [courseId, fetchComments]);
+
+ useEffect(() => {
+ if (page > 1) {
+ void fetchComments(page, true);
+ }
+ }, [page, fetchComments]);
+
+ useEffect(() => {
+ if (!currentUser?._id) return;
+ const alreadyRated = comments.some(
+ (item) =>
+ String(item.user?._id) === String(currentUser._id) &&
+ Number(item.rate) > 0
+ );
+ setHasRatedCourse(alreadyRated);
+ }, [comments, currentUser?._id]);
+
+ const lastCommentRef = useCallback(
+ (node: HTMLDivElement | null) => {
+ if (loading) return;
+ if (observer.current) observer.current.disconnect();
+ observer.current = new IntersectionObserver((entries) => {
+ if (entries[0].isIntersecting && hasMore) {
+ setPage((prev) => prev + 1);
+ }
+ });
+ if (node) observer.current.observe(node);
+ },
+ [loading, hasMore]
+ );
+
+ const sendMessageHandler = async () => {
+ if (!requireAuth()) return;
+ if (!newMessage.trim()) {
+ toast.error(t("posts.commentRequired"));
+ return;
+ }
+ if (newMessage.length > 500) {
+ toast.error(t("posts.commentTooLong"));
+ return;
+ }
+
+ try {
+ const payload: Record = {
+ course_id: courseId,
+ comment: newMessage,
+ };
+ if (!hasRatedCourse && rating > 0) {
+ payload.rate = rating;
+ }
+
+ const response = await request("POST", "/academy/comment/create", payload);
+
+ if (response?.success) {
+ setNewMessage("");
+ setRating(0);
+ if (!hasRatedCourse && rating > 0) {
+ setHasRatedCourse(true);
+ }
+ setPage(1);
+ setComments([]);
+ await fetchComments(1, false);
+ onCommentAdded?.();
+ }
+ } catch (error: unknown) {
+ console.error("Error sending comment:", error);
+ toast.error(t("posts.commentError"));
+ }
+ };
+
+ const composerPad =
+ variant === "sheet"
+ ? "pb-[calc(0.75rem+env(safe-area-inset-bottom))]"
+ : "pb-[calc(5.5rem+env(safe-area-inset-bottom))]";
+
+ return (
+
+
+
{t("posts.comments")}
+ {variant === "sheet" && onClose ? (
+
+
+
+ ) : null}
+
+
+
+ {comments.length === 0 && !loading ? (
+
{t("posts.noComments")}
+ ) : (
+ comments.map((item, index) => (
+
+
+
+ ))
+ )}
+ {loading ? (
+
+ {t("common.loading")}
+
+ ) : null}
+
+
+
+ {!hasRatedCourse && (
+
+ {t("posts.ratingOptional")}
+ setRating(v)}
+ count={5}
+ style={{ fontSize: 28, color: "#f59e0b" }}
+ />
+
+ )}
+
+
+
+
+ {
+ if (e.target.value.length <= 500) setNewMessage(e.target.value);
+ }}
+ className="flex-1"
+ />
+
+
+
+ );
+}
diff --git a/src/components/academy/InfinitePakage.tsx b/src/components/academy/InfinitePakage.tsx
index cbdffe6..b029094 100644
--- a/src/components/academy/InfinitePakage.tsx
+++ b/src/components/academy/InfinitePakage.tsx
@@ -1,6 +1,8 @@
"use client";
-import React, { useEffect, useCallback, useState, useRef } from "react";
+import React, { useEffect, useCallback, useState, useRef, useMemo } from "react";
+import { useRouter } from "next/navigation";
+import { useListScrollRestoration } from "@/hooks/useListScrollRestoration";
import MainModelCard from "./MainModelCard";
import { Course } from "@/types/types";
import Image from "next/image";
@@ -10,6 +12,7 @@ import { motion, AnimatePresence } from "framer-motion";
import { AcademyListSkeleton } from "./AcademySkeletons";
import { btnPrimary } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
+import { useTranslation } from "react-i18next";
interface InfinitePostsProps {
token: string;
@@ -36,6 +39,7 @@ interface InfinitePostsProps {
}
export default function InfinitePosts({ filters, token }: InfinitePostsProps) {
+ const { t } = useTranslation("common");
const [courses, setCourses] = useState([]);
const [coursesPage, setCoursesPage] = useState(1);
const [hasMoreCourses, setHasMoreCourses] = useState(true);
@@ -45,6 +49,13 @@ export default function InfinitePosts({ filters, token }: InfinitePostsProps) {
const loadMoreRef = useRef(null);
const { request } = useAxios();
+ const filterKey = useMemo(() => JSON.stringify(filters), [filters]);
+ const scrollKey = useListScrollRestoration(
+ "academy",
+ { f: filterKey },
+ !isInitialCoursesLoading
+ );
+
// ساخت query string از فیلترها
const buildQueryString = (page: number) => {
const params = new URLSearchParams();
@@ -151,7 +162,7 @@ export default function InfinitePosts({ filters, token }: InfinitePostsProps) {
setCoursesPage(page);
} catch (err: any) {
console.error("get error:", err);
- toast.error(err?.message || "خطا در دریافت اطلاعات");
+ toast.error(err?.message || t("academy.loadError"));
setHasMoreCourses(false);
} finally {
if (isLoadMore) {
@@ -224,20 +235,20 @@ export default function InfinitePosts({ filters, token }: InfinitePostsProps) {
📚
- هیچ دورهای با این فیلترها یافت نشد
+ {t("academy.noCoursesFiltered")}
- لطفاً فیلترهای دیگری را امتحان کنید
+ {t("academy.tryOtherFilters")}
);
}
return (
-
+
{/* نمایش تعداد نتایج */}
- {totalItems.toLocaleString()} دوره پیدا شد
+ {t("academy.coursesFound", { count: totalItems.toLocaleString() })}
{/* نمایش دورهها */}
@@ -250,7 +261,7 @@ export default function InfinitePosts({ filters, token }: InfinitePostsProps) {
transition={{ delay: index * 0.05 }}
className="space-y-2"
>
-
+
))}
@@ -271,7 +282,7 @@ export default function InfinitePosts({ filters, token }: InfinitePostsProps) {
onClick={() => fetchCourses(coursesPage + 1, true)}
className={cn(btnPrimary, "px-4 py-2 text-sm")}
>
- بارگذاری بیشتر
+ {t("academy.loadMore")}
)}
@@ -281,7 +292,7 @@ export default function InfinitePosts({ filters, token }: InfinitePostsProps) {
{/* پیام پایان لیست */}
{!hasMoreCourses && courses.length > 0 && (
- به انتهای لیست دورهها رسیدید ({totalItems.toLocaleString()} دوره)
+ {t("academy.endOfList", { count: totalItems.toLocaleString() })}
)}
diff --git a/src/components/academy/MainModelCard.tsx b/src/components/academy/MainModelCard.tsx
index 67be520..cf18a7d 100644
--- a/src/components/academy/MainModelCard.tsx
+++ b/src/components/academy/MainModelCard.tsx
@@ -12,6 +12,11 @@ import { Academy, Course } from "@/types/types";
import Image from "next/image";
import MainModelCardActions from "./MainModelCardActions";
import Link from "next/link";
+import ExpandableCaption from "@/components/posts/ExpandableCaption";
+import {
+ reorderCaptionLinksToEnd,
+ splitCaptionBodyAndLinks,
+} from "@/lib/postLinkCaption";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import { useUserById } from "@/hooks/getUserById";
@@ -19,8 +24,11 @@ import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
import useAxios from "@/hooks/useAxios";
import { useRouter } from "next/navigation";
+import { navigateWithSavedScroll } from "@/lib/listScrollRestoration";
import { forEach } from "lodash";
import { formatCoursePriceLabel } from "@/lib/formatCoursePriceLabel";
+import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارتها
const MutedContext = createContext<{
@@ -35,7 +43,15 @@ interface AcademyResponse {
academy: Academy;
}
-function MainModelCard({ postData }: { postData: Course }) {
+function MainModelCard({
+ postData,
+ scrollKey,
+}: {
+ postData: Course;
+ scrollKey?: string;
+}) {
+ const { t, i18n } = useTranslation("common");
+ const priceLang = i18n.language === "en" ? "en" : "fa";
const {
user_id,
caption,
@@ -177,9 +193,9 @@ function MainModelCard({ postData }: { postData: Course }) {
// ترکیب کامنتها با اطلاعات کاربران
const commentsWithUsers = newComments.map((comment) => ({
...comment,
- user_id: userMap[comment.user_id] || {
+ user_id: userMap[comment.user_id] || {
_id: comment.user_id,
- user_name: "کاربر ناشناس",
+ user_name: t("common.user"),
profile_image: null,
is_verified: "unverified"
},
@@ -200,7 +216,7 @@ function MainModelCard({ postData }: { postData: Course }) {
}
} catch (error) {
console.error("Error fetching comments:", error);
- toast.error("خطا در دریافت نظرات");
+ toast.error(t("academy.commentsError"));
} finally {
setLoading(false);
}
@@ -254,6 +270,13 @@ function MainModelCard({ postData }: { postData: Course }) {
}, []);
const [showFullCaption, setShowFullCaption] = useState(false);
+ const orderedCaption = reorderCaptionLinksToEnd(caption || "");
+ const { body: captionBody, links: captionLinks } =
+ splitCaptionBodyAndLinks(orderedCaption);
+
+ useEffect(() => {
+ setShowFullCaption(false);
+ }, [postData._id]);
const [imageLoading, setImageLoading] = useState(true);
const videoRef = useRef(null);
@@ -266,8 +289,6 @@ function MainModelCard({ postData }: { postData: Course }) {
// استفاده از Context برای وضعیت muted گلوبال
const { muted, setMuted } = useContext(MutedContext);
- const displayCaption = showFullCaption ? caption : caption?.slice(0, 40);
-
const subExpertise = user?.sub_expertise || [];
const keywords = ["نمونه کار دارم", "آرایشگر سیار", "عکاس سیار"];
const keywords2 = ["آموزشگاه دارم"];
@@ -347,7 +368,7 @@ function MainModelCard({ postData }: { postData: Course }) {
return (
- تعداد ویدیو : {number_of_course_content}
+ {t("academy.videoCount", { count: number_of_course_content })}
{category}
{matchedExpertise.map((item, index) => (
@@ -404,7 +425,7 @@ function MainModelCard({ postData }: { postData: Course }) {
- {formatCoursePriceLabel(postData)}
+ {formatCoursePriceLabel(postData, priceLang)}
{!postData.is_free && postData.offer && Number(postData.offer) > 0 ? (
%{postData.offer}
@@ -422,8 +443,8 @@ function MainModelCard({ postData }: { postData: Course }) {
@@ -431,8 +452,8 @@ function MainModelCard({ postData }: { postData: Course }) {
@@ -440,8 +461,18 @@ function MainModelCard({ postData }: { postData: Course }) {
-
router.push(`/academy/${postData._id}/course`)} className="overflow-hidden rounded-2xl">
-
+
{
+ const url = `/academy/${postData._id}/course`;
+ if (scrollKey) {
+ navigateWithSavedScroll(router, scrollKey, url);
+ } else {
+ router.push(url);
+ }
+ }}
+ className="overflow-hidden rounded-2xl"
+ >
+
-
- {(showFullCaption
- ? caption
- : caption?.slice(0, 40) +
- (caption && caption.length > 40 ? " " : " ")
- )
- ?.split(/(\s+)/)
- .map((part, index) =>
- part.startsWith("#") ? (
-
- {part}
-
- ) : (
- part
- )
- )}
- {caption && caption.length > 40 && (
- setShowFullCaption(!showFullCaption)}
- >
- {showFullCaption ? "کمتر" : "بیشتر"}
-
- )}
-
+ {(captionBody || captionLinks) && (
+
+ setShowFullCaption((v) => !v)}
+ />
+
+ )}
);
}
diff --git a/src/components/posts/ReelsPostOptionsMenu.tsx b/src/components/posts/ReelsPostOptionsMenu.tsx
new file mode 100644
index 0000000..8f1684b
--- /dev/null
+++ b/src/components/posts/ReelsPostOptionsMenu.tsx
@@ -0,0 +1,219 @@
+"use client";
+
+import React, { useState } from "react";
+import Modal from "@/components/elements/Modal";
+import RoundedButton from "@/components/elements/RoundedButton";
+import BoldIcon from "@/components/ui/BoldIcon";
+import { FiMoreHorizontal } from "react-icons/fi";
+import { Post } from "@/types/types";
+import useAxios from "@/hooks/useAxios";
+import toast from "react-hot-toast";
+import { getStoredUserId } from "@/lib/auth/session";
+import ReelsPostAnalyticsModal from "./ReelsPostAnalyticsModal";
+import { useTranslation } from "react-i18next";
+import { useAppLanguage } from "@/contexts/LanguageProvider";
+
+const REPORT_REASON_IDS = [
+ "inappropriate",
+ "spam",
+ "fraud",
+ "harassment",
+ "other",
+] as const;
+
+type Props = {
+ postData: Post;
+ isOwnPost: boolean;
+ viewCount?: number;
+ variant?: "default" | "reels";
+};
+
+export default function ReelsPostOptionsMenu({
+ postData,
+ isOwnPost,
+ viewCount = 0,
+ variant = "default",
+}: Props) {
+ const { t } = useTranslation("common");
+ const { language } = useAppLanguage();
+ const numberLocale = language === "en" ? "en-US" : "fa-IR";
+ const { request } = useAxios();
+ const [open, setOpen] = useState(false);
+ const [reportOpen, setReportOpen] = useState(false);
+ const [analyticsOpen, setAnalyticsOpen] = useState(false);
+ const [reportReason, setReportReason] = useState("");
+ const [reportText, setReportText] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+
+ const submitReport = async () => {
+ if (!reportReason.trim()) {
+ toast.error(t("posts.selectReportReason"));
+ return;
+ }
+ setSubmitting(true);
+ try {
+ await request(
+ "POST",
+ "/posts/report",
+ {
+ postId: postData._id,
+ reason: reportReason,
+ text: reportText,
+ },
+ { noToast: true }
+ );
+ toast.success(t("posts.reportSubmitted"));
+ setReportOpen(false);
+ setOpen(false);
+ setReportReason("");
+ setReportText("");
+ } catch {
+ toast.error(t("posts.reportFailed"));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const pinPost = async () => {
+ try {
+ await request(
+ "POST",
+ "/account/pin-post",
+ { postId: postData._id },
+ { noToast: true }
+ );
+ toast.success(t("posts.postPinned"));
+ setOpen(false);
+ } catch {
+ toast.error(t("posts.pinFailed"));
+ }
+ };
+
+ return (
+ <>
+ setOpen(true)}
+ className={
+ variant === "reels"
+ ? "inline-flex shrink-0 items-center justify-center"
+ : "gentle-transition flex h-10 w-10 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-md active:scale-90"
+ }
+ aria-label={t("posts.moreOptions")}
+ >
+ {variant === "reels" ? (
+
+ ) : (
+
+ )}
+
+
+ setOpen(false)}
+ height="fit"
+ panelClassName="!p-0 max-h-[70dvh] overflow-hidden"
+ >
+
+ {
+ setOpen(false);
+ setReportOpen(true);
+ }}
+ className="px-5 py-3.5 text-right text-sm font-semibold text-red-500 active:bg-neutral-100 dark:active:bg-neutral-800"
+ >
+ {t("posts.reportPost")}
+
+ setOpen(false)}
+ className="px-5 py-3.5 text-right text-sm active:bg-neutral-100 dark:active:bg-neutral-800"
+ >
+ {t("posts.postViews", {
+ count: viewCount.toLocaleString(numberLocale),
+ })}
+
+ {isOwnPost ? (
+ <>
+ {
+ setOpen(false);
+ setAnalyticsOpen(true);
+ }}
+ className="px-5 py-3.5 text-right text-sm active:bg-neutral-100 dark:active:bg-neutral-800"
+ >
+ {t("posts.viewAnalytics")}
+
+ void pinPost()}
+ className="px-5 py-3.5 text-right text-sm active:bg-neutral-100 dark:active:bg-neutral-800"
+ >
+ {t("posts.pinPost")}
+
+ >
+ ) : null}
+
+
+
+ setReportOpen(false)}
+ height="fit"
+ panelClassName="!p-4"
+ >
+
+ {t("posts.reportReasonTitle")}
+
+ setReportReason(e.target.value)}
+ className="mb-3 w-full rounded-xl border border-neutral-200 bg-transparent px-3 py-2 text-sm dark:border-neutral-700"
+ >
+ {t("posts.selectReason")}
+ {REPORT_REASON_IDS.map((id) => (
+
+ {t(`posts.reportReasons.${id}`)}
+
+ ))}
+
+ setReportText(e.target.value)}
+ placeholder={t("posts.reportNotesPlaceholder")}
+ rows={3}
+ className="mb-4 w-full resize-none rounded-xl border border-neutral-200 bg-transparent px-3 py-2 text-sm dark:border-neutral-700"
+ />
+ void submitReport()}
+ className="h-10 w-full text-sm"
+ >
+ {submitting ? t("posts.submittingReport") : t("posts.submitReport")}
+
+
+
+ {analyticsOpen ? (
+ setAnalyticsOpen(false)}
+ />
+ ) : null}
+ >
+ );
+}
diff --git a/src/components/posts/ReelsTabBar.tsx b/src/components/posts/ReelsTabBar.tsx
index 1c8ad57..8336920 100644
--- a/src/components/posts/ReelsTabBar.tsx
+++ b/src/components/posts/ReelsTabBar.tsx
@@ -1,15 +1,12 @@
"use client";
-import React from "react";
+import React, { useMemo } from "react";
+import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
-export type ReelsTab = "for_you" | "following" | "saved";
+export type ReelsTab = "for_you" | "following" | "saved" | "near_me";
-const TABS: { id: ReelsTab; label: string }[] = [
- { id: "for_you", label: "برای شما" },
- { id: "following", label: "دنبالشوندهها" },
- { id: "saved", label: "ذخیرهشده" },
-];
+const TAB_IDS: ReelsTab[] = ["for_you", "following", "saved", "near_me"];
interface ReelsTabBarProps {
activeTab: ReelsTab;
@@ -22,6 +19,8 @@ export default function ReelsTabBar({
onChange,
className,
}: ReelsTabBarProps) {
+ const { t } = useTranslation("common");
+
return (
- {TABS.map((tab) => {
- const isActive = tab.id === activeTab;
+ {TAB_IDS.map((tabId) => {
+ const isActive = tabId === activeTab;
return (
onChange(tab.id)}
+ onClick={() => onChange(tabId)}
className={cn(
"gentle-transition text-sm drop-shadow-[0_1px_3px_rgba(0,0,0,0.85)]",
isActive
@@ -47,7 +46,7 @@ export default function ReelsTabBar({
: "font-semibold text-white/60 active:text-white/85"
)}
>
- {tab.label}
+ {t(`reels.tabs.${tabId}`)}
);
})}
@@ -56,6 +55,12 @@ export default function ReelsTabBar({
}
export function parseReelsTab(value: string | null): ReelsTab {
- if (value === "following" || value === "saved") return value;
+ if (
+ value === "following" ||
+ value === "saved" ||
+ value === "near_me"
+ ) {
+ return value;
+ }
return "for_you";
}
diff --git a/src/components/posts/SendPostModal.tsx b/src/components/posts/SendPostModal.tsx
index 568f68d..a6a7eb9 100644
--- a/src/components/posts/SendPostModal.tsx
+++ b/src/components/posts/SendPostModal.tsx
@@ -12,6 +12,7 @@ import toast from "react-hot-toast";
import IOSSpinner from "@/components/ui/IOSSpinner";
import { Post } from "@/types/types";
import { buildPostPublicUrl } from "@/lib/postSlug";
+import { useTranslation } from "react-i18next";
export interface ChatUserItem {
_id: string;
@@ -36,6 +37,7 @@ export default function SendPostModal({
currentUserId,
onSent,
}: SendPostModalProps) {
+ const { t } = useTranslation("common");
const { request } = useAxios();
const [users, setUsers] = useState
([]);
const [selected, setSelected] = useState>(new Set());
@@ -72,7 +74,7 @@ export default function SendPostModal({
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else if (next.size < 50) next.add(id);
- else toast.error("حداکثر ۵۰ کاربر");
+ else toast.error(t("posts.maxUsers"));
return next;
});
};
@@ -90,12 +92,14 @@ export default function SendPostModal({
},
{ noToast: true }
);
- toast.success(`پست به ${res?.sentCount ?? selected.size} نفر ارسال شد`);
+ toast.success(
+ t("posts.sentToCount", { count: res?.sentCount ?? selected.size })
+ );
onSent?.(res?.sendsCount ?? 0);
onClose();
setSelected(new Set());
} catch {
- toast.error("خطا در ارسال پست");
+ toast.error(t("posts.sendPostError"));
} finally {
setSending(false);
}
@@ -122,7 +126,7 @@ export default function SendPostModal({
className="glass-modal-panel w-full max-w-md rounded-t-3xl p-4 sm:rounded-3xl"
onClick={(e) => e.stopPropagation()}
>
- ارسال پست
+ {t("posts.sendPost")}
{preview && (
@@ -147,7 +151,7 @@ export default function SendPostModal({
) : users.length === 0 ? (
- کاربری در لیست چت شما نیست
+ {t("posts.noChatUsers")}
) : (
@@ -204,7 +208,9 @@ export default function SendPostModal({
onClick={send}
className={cn(btnPrimary, "mt-4 w-full py-3 text-sm font-bold disabled:opacity-40")}
>
- {sending ? "در حال ارسال..." : `ارسال (${selected.size})`}
+ {sending
+ ? t("posts.sending")
+ : t("posts.sendWithCount", { count: selected.size })}
diff --git a/src/components/posts/TagUsersPicker.tsx b/src/components/posts/TagUsersPicker.tsx
index 6856445..2bc8a3c 100644
--- a/src/components/posts/TagUsersPicker.tsx
+++ b/src/components/posts/TagUsersPicker.tsx
@@ -6,6 +6,7 @@ import useAxios from "@/hooks/useAxios";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import BoldIcon from "@/components/ui/BoldIcon";
import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
export interface TaggedUser {
_id: string;
@@ -39,6 +40,7 @@ export default function TagUsersPicker({
onChange,
max = 50,
}: TagUsersPickerProps) {
+ const { t } = useTranslation("common");
const { request } = useAxios();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
@@ -75,7 +77,7 @@ export default function TagUsersPicker({
const addUser = (u: TaggedUser) => {
if (selected.some((s) => s._id === u._id)) return;
if (selected.length >= max) {
- toast.error(`حداکثر ${max} نفر`);
+ toast.error(t("posts.maxTagged", { max }));
return;
}
onChange([...selected, u]);
@@ -94,7 +96,7 @@ export default function TagUsersPicker({
className="flex w-full items-center justify-center gap-2 rounded-full border border-neutral-200 py-2.5 text-sm font-medium transition active:scale-[0.98] dark:border-neutral-700"
>
- تگ کردن افراد ({selected.length}/{max})
+ {t("posts.tagPeople", { count: selected.length, max })}
{selected.length > 0 && (
@@ -125,7 +127,7 @@ export default function TagUsersPicker({
removeUser(u._id)}
- aria-label="حذف"
+ aria-label={t("posts.remove")}
>
setQuery(e.target.value)}
- placeholder="جستجو با نام کاربری، نام یا نام خانوادگی…"
+ placeholder={t("posts.searchUsersPlaceholder")}
className="w-full rounded-full bg-neutral-100 px-4 py-2.5 text-sm outline-none dark:bg-neutral-800"
autoFocus
/>
{searching && (
- در حال جستجو…
+ {t("posts.searching")}
)}
{!searching && query.trim().length >= 2 && results.length === 0 && (
- کاربری یافت نشد
+ {t("posts.noUsersFound")}
)}
diff --git a/src/components/posts/VideoCoverPicker.tsx b/src/components/posts/VideoCoverPicker.tsx
new file mode 100644
index 0000000..06e546a
--- /dev/null
+++ b/src/components/posts/VideoCoverPicker.tsx
@@ -0,0 +1,148 @@
+"use client";
+
+import React, { useEffect, useRef, useState } from "react";
+import Image from "next/image";
+import BoldIcon from "@/components/ui/BoldIcon";
+import { captureVideoFrame } from "@/lib/postLocationNotice";
+import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
+
+type Props = {
+ videoUrl: string;
+ onCoverChange: (file: File | null) => void;
+};
+
+export default function VideoCoverPicker({ videoUrl, onCoverChange }: Props) {
+ const { t } = useTranslation("common");
+ const videoRef = useRef(null);
+ const [duration, setDuration] = useState(0);
+ const [time, setTime] = useState(0);
+ const [coverPreview, setCoverPreview] = useState("");
+ const [mode, setMode] = useState<"frame" | "upload">("frame");
+
+ useEffect(() => {
+ return () => {
+ if (coverPreview) URL.revokeObjectURL(coverPreview);
+ };
+ }, [coverPreview]);
+
+ const applyFrameAt = async (seconds: number) => {
+ const video = videoRef.current;
+ if (!video) return;
+ try {
+ const blob = await captureVideoFrame(video, seconds);
+ const file = new File([blob], `cover-${Date.now()}.webp`, {
+ type: "image/webp",
+ });
+ if (coverPreview) URL.revokeObjectURL(coverPreview);
+ const preview = URL.createObjectURL(blob);
+ setCoverPreview(preview);
+ onCoverChange(file);
+ setMode("frame");
+ toast.success(t("posts.coverSelected"));
+ } catch {
+ toast.error(t("posts.framePickFailed"));
+ }
+ };
+
+ const onUploadCover = (e: React.ChangeEvent) => {
+ const picked = e.target.files?.[0];
+ if (!picked || !picked.type.startsWith("image/")) {
+ toast.error(t("posts.imagesOnly"));
+ return;
+ }
+ if (coverPreview) URL.revokeObjectURL(coverPreview);
+ setCoverPreview(URL.createObjectURL(picked));
+ onCoverChange(picked);
+ setMode("upload");
+ toast.success(t("posts.coverUploaded"));
+ };
+
+ return (
+
+
{t("posts.videoCover")}
+
+
+ setMode("frame")}
+ className={`rounded-full px-3 py-1.5 text-xs font-semibold ${
+ mode === "frame"
+ ? "bg-neutral-900 text-white dark:bg-white dark:text-neutral-900"
+ : "bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300"
+ }`}
+ >
+ {t("posts.pickFromVideo")}
+
+
+ {t("posts.uploadCover")}
+
+
+
+
+
+
{
+ const d = e.currentTarget.duration || 0;
+ setDuration(d);
+ setTime(0);
+ }}
+ />
+ {coverPreview ? (
+
+
+
+ ) : null}
+
+
+ {mode === "frame" && duration > 0 ? (
+
+ {
+ const next = parseFloat(e.target.value);
+ setTime(next);
+ if (videoRef.current) videoRef.current.currentTime = next;
+ }}
+ className="w-full accent-[#0095f6]"
+ />
+ void applyFrameAt(time)}
+ className="flex w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 py-2.5 text-sm font-semibold dark:bg-neutral-800"
+ >
+
+ {t("posts.pickFrameAsCover")}
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/profile/ExpandableBio.tsx b/src/components/profile/ExpandableBio.tsx
new file mode 100644
index 0000000..071f7e9
--- /dev/null
+++ b/src/components/profile/ExpandableBio.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { useState } from "react";
+import { cn } from "@/lib/utils";
+import {
+ getCollapsedCaptionPreview,
+ postCaptionNeedsMore,
+} from "@/lib/postLinkCaption";
+import { useTranslation } from "react-i18next";
+
+type ExpandableBioProps = {
+ bio?: string | null;
+ className?: string;
+ moreClassName?: string;
+};
+
+export default function ExpandableBio({
+ bio,
+ className,
+ moreClassName = "text-blue-500 text-xs font-semibold",
+}: ExpandableBioProps) {
+ const { t } = useTranslation("common");
+ const [showFull, setShowFull] = useState(false);
+ const text = bio?.trim() || "";
+ if (!text) return null;
+
+ const needsMore = postCaptionNeedsMore(text);
+ const displayed =
+ showFull || !needsMore ? text : getCollapsedCaptionPreview(text);
+
+ return (
+
+ {displayed}
+ {needsMore ? (
+ <>
+ {" "}
+ setShowFull((v) => !v)}
+ >
+ {showFull ? t("posts.less") : t("posts.more")}
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/src/components/projects/FilterModal.tsx b/src/components/projects/FilterModal.tsx
index 39d2bed..d4ce911 100644
--- a/src/components/projects/FilterModal.tsx
+++ b/src/components/projects/FilterModal.tsx
@@ -1,4 +1,7 @@
+"use client";
+
import React, { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
import Modal from "../elements/Modal";
import RoundedButton from "../elements/RoundedButton";
import { btnHeightSm, filterChipClass, toggleBtnClass } from "@/lib/ui/buttonStyles";
@@ -35,6 +38,7 @@ const FilterModal = ({
handleFilterChange,
clearFilters,
}: IFilterProps) => {
+ const { t } = useTranslation("common");
const { request } = useAxios();
const [expertiseList, setExpertiseList] = useState([]);
const [loadingExpertise, setLoadingExpertise] = useState(false);
@@ -74,13 +78,17 @@ const FilterModal = ({
>
-
فیلتر پروژهها
+
+ {t("filters.projectsTitle")}
+
-
تخصص
+
+ {t("filters.expertise")}
+
{loadingExpertise ? (
- در حال بارگذاری تخصصها...
+ {t("filters.loadingExpertise")}
) : (
@@ -89,7 +97,7 @@ const FilterModal = ({
className={filterChipClass(!selectedExpertise)}
onClick={() => handleExpertiseSelect("")}
>
- همه
+ {t("filters.all")}
{expertiseList.map((item) => {
const isActive = selectedExpertise === item.expertise;
@@ -111,7 +119,7 @@ const FilterModal = ({
- مرتب سازی
+ {t("filters.sortTitle")}
- بیشترین پیشنهاد
+ {t("filters.mostOffers")}
- بیشترین بودجه
+ {t("filters.mostBudget")}
-
سن
+
+ {t("filters.age")}
+
setFilterAge("all")}
className={cn(toggleBtnClass(filterAge === "all"), btnHeightSm, "w-full")}
>
- بدون محدودیت
+ {t("filters.noAgeLimit")}
-
جنسیت
+
+ {t("filters.gender")}
+
- مرد
+ {t("auth.male")}
- زن
+ {t("auth.female")}
@@ -203,14 +215,14 @@ const FilterModal = ({
}}
className="h-10 w-full max-w-[200px] text-sm"
>
- اعمال فیلتر
+ {t("filters.apply")}
clearFilters()}
className="h-10 w-full max-w-[200px] text-sm"
>
- پاک کردن
+ {t("filters.clear")}
diff --git a/src/components/projects/InfiniteProjects.tsx b/src/components/projects/InfiniteProjects.tsx
index 4f50bbc..ac3a9ed 100644
--- a/src/components/projects/InfiniteProjects.tsx
+++ b/src/components/projects/InfiniteProjects.tsx
@@ -3,6 +3,9 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import React, { useEffect, useRef } from "react";
+import { navigateWithSavedScroll } from "@/lib/listScrollRestoration";
+import { useListScrollRestoration } from "@/hooks/useListScrollRestoration";
+import { useTranslation } from "react-i18next";
import { fetchProjects } from "@/api/fetchProjects";
import MainProjectCard from "./MainProjectCard";
import { Project } from "@/types/types";
@@ -25,10 +28,10 @@ export default function InfiniteProjects({
filters,
token,
}: InfiniteProjectsProps) {
+ const { t } = useTranslation("common");
const observerRef = useRef
(null);
const router = useRouter();
- // تبدیل فیلترها به رشته برای استفاده در queryKey
const filterString = JSON.stringify({
expertise: filters.expertise || '',
most_requests: filters.most_requests || '',
@@ -42,6 +45,7 @@ export default function InfiniteProjects({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
+ isLoading,
refetch
} = useInfiniteQuery({
queryKey: ['projects', filterString],
@@ -53,10 +57,15 @@ export default function InfiniteProjects({
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) =>
lastPage.totalItems > allPages.length * 10 ? allPages.length + 1 : undefined,
- staleTime: 10000, // 10 ثانیه
+ staleTime: 10000,
});
- // بازخوانی دادهها هنگام تغییر فیلترها
+ const scrollKey = useListScrollRestoration(
+ "projects",
+ { f: filterString },
+ !isLoading
+ );
+
useEffect(() => {
refetch();
}, [filterString, refetch]);
@@ -86,28 +95,35 @@ export default function InfiniteProjects({
return (
-
+
{data?.pages?.some((page) => page?.projects?.length) ? (
data.pages.map((page, pageIndex) =>
page?.projects?.map((item: Project) => (
-
router.push(`/projects/${item?._id}/${item?.title}`)}
- className="cursor-pointer"
- key={`${item._id}-${pageIndex}`}
- >
-
+
+
+ navigateWithSavedScroll(
+ router,
+ scrollKey,
+ `/projects/${item?._id}/${item?.title}`
+ )
+ }
+ className="cursor-pointer"
+ >
+
+
))
)
) : (
- پروژهای برای نمایش وجود ندارد.
+ {t("projects.empty")}
)}
- {isFetchingNextPage &&
در حال بارگذاری...
}
+ {isFetchingNextPage &&
{t("common.loading")}
}
);
-}
\ No newline at end of file
+}
diff --git a/src/components/projects/MainProjectCard.tsx b/src/components/projects/MainProjectCard.tsx
index f25083f..fc60049 100644
--- a/src/components/projects/MainProjectCard.tsx
+++ b/src/components/projects/MainProjectCard.tsx
@@ -1,5 +1,8 @@
+"use client";
+
import { Project } from "@/types/types";
import React from "react";
+import { useTranslation } from "react-i18next";
import RoundedDiv from "../elements/RoundedDiv";
import Image from "next/image";
import Link from "next/link";
@@ -24,6 +27,21 @@ function MainProjectCard({
isSample?: boolean;
sampleType?: string;
}) {
+ const { t } = useTranslation("common");
+
+ const getAgeLabel = (age?: string) => {
+ if (!age || age === "all") return t("filters.all");
+ if (age === "old" || age === "25-30") return "25-30";
+ if (age === "18-25") return "18-25";
+ return age;
+ };
+
+ const getGenderLabel = (gender?: string) => {
+ if (gender === "male") return t("auth.male");
+ if (gender === "female") return t("auth.female");
+ return "";
+ };
+
return (
{project?.project_type === "force" && (
- فوری
+ {t("projects.urgent")}
)}
{isSample && sampleType == "force" && (
- فوری
+ {t("projects.urgent")}
)}
{statusType && (
@@ -84,7 +102,7 @@ function MainProjectCard({
""
)}
{project?.gender && (
-
{project?.gender == "male" ? " آقا" : " خانم"}
+
{getGenderLabel(project.gender)}
)}
{full &&
}
@@ -100,7 +118,7 @@ function MainProjectCard({
src="/images/icons/location.svg"
className="dark:invert"
/>
-
لوکیشن: شهر
+
{t("projects.locationCity")}
{project?.city && project?.city?.name}
@@ -111,8 +129,10 @@ function MainProjectCard({
src="/images/icons/clock.svg"
className="dark:invert"
/>
- زمان پیشنهادی:
- {project?.offer_time} روز
+ {t("projects.suggestedTime")}
+
+ {project?.offer_time} {t("projects.days")}
+
- رده سنی:
- {project?.age == "all" ? "همه" : project?.age}
+ {t("projects.ageRange")}
+ {getAgeLabel(project?.age)}
- جنسیت:
- {project?.gender == "male" ? "آقا" : "خانم"}
+ {t("filters.gender")}:
+ {getGenderLabel(project?.gender)}
@@ -145,7 +165,7 @@ function MainProjectCard({
: ""
}`}
>
- بودجه: {Number(project?.offer_price).toLocaleString()}
+ {t("projects.budget")} {Number(project?.offer_price).toLocaleString()}
- تعداد پیشنهاد: {project?.requested_users?.length}
+ {t("projects.offerCount")} {project?.requested_users?.length}
diff --git a/src/components/projects/NewProject/Step1.tsx b/src/components/projects/NewProject/Step1.tsx
index d9de730..a415ee4 100644
--- a/src/components/projects/NewProject/Step1.tsx
+++ b/src/components/projects/NewProject/Step1.tsx
@@ -5,33 +5,45 @@ import { useProjectForm } from "@/contexts/ProjectFormContext";
import useAxios from "@/hooks/useAxios";
import { IExpertise } from "@/types/types";
import { useFormik } from "formik";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import * as Yup from "yup";
+import { useTranslation } from "react-i18next";
import GenderSelection from "./step1/GenderSelection";
import AgeSelection from "./step1/AgeSelection";
import PublicTypeSelection from "./step1/PublicTypeSelection";
import LocationSelector from "./step1/LocationSelector";
import ExpertiseSelector from "./step1/ExpertiseSelector";
-const validationSchema = Yup.object({
- cityId: Yup.string().required("انتخاب شهر الزامی است"),
- stateId: Yup.string().required("انتخاب استان الزامی است"),
- publicType: Yup.string().required("انتخاب روابط عمومی الزامی است"),
- age: Yup.string().required("انتخاب محدوده سنی الزامی است"),
- gender: Yup.string().required("انتخاب جنسیت الزامی است"),
- expertise: Yup.string().required("انتخاب تخصص الزامی است"),
- subExpertise: Yup.array()
- .of(Yup.string().required("حداقل یک زیرمهارت را انتخاب کنید"))
- .min(1, "حداقل یک زیرمهارت را انتخاب کنید"),
-});
-
const Step1 = ({ nextStep }: { nextStep: () => void }) => {
+ const { t } = useTranslation("common");
const { formData, updateForm } = useProjectForm();
const { request } = useAxios();
const [expertiseList, setExpertiseList] = useState(null);
const [expertise, setExpertise] = useState("");
const [subExpertise, setSubExpertise] = useState([]);
+ const validationSchema = useMemo(
+ () =>
+ Yup.object({
+ cityId: Yup.string().required(t("projects.newProject.validation.cityRequired")),
+ stateId: Yup.string().required(t("projects.newProject.validation.stateRequired")),
+ publicType: Yup.string().required(
+ t("projects.newProject.validation.publicTypeRequired")
+ ),
+ age: Yup.string().required(t("projects.newProject.validation.ageRequired")),
+ gender: Yup.string().required(t("projects.newProject.validation.genderRequired")),
+ expertise: Yup.string().required(
+ t("projects.newProject.validation.expertiseRequired")
+ ),
+ subExpertise: Yup.array()
+ .of(
+ Yup.string().required(t("projects.newProject.validation.subExpertiseRequired"))
+ )
+ .min(1, t("projects.newProject.validation.subExpertiseRequired")),
+ }),
+ [t]
+ );
+
useEffect(() => {
const fetchData = async () => {
try {
@@ -55,7 +67,7 @@ const Step1 = ({ nextStep }: { nextStep: () => void }) => {
expertise: formData.expertise || "",
gender: formData.gender || "",
age: formData.age || "",
- subExpertise: formData.subExpertise || [], // آرایهای از رشتهها
+ subExpertise: formData.subExpertise || [],
},
validationSchema,
onSubmit: (values) => {
@@ -69,7 +81,7 @@ const Step1 = ({ nextStep }: { nextStep: () => void }) => {
onSubmit={formik.handleSubmit}
className="flex flex-col gap-2 w-full items-center text-sm"
>
- ثبت درخواست
+ {t("projects.newProject.title")}
void }) => {
- ثبت و ادامه
+ {t("projects.newProject.submitContinue")}
);
diff --git a/src/components/projects/NewProject/Step2.tsx b/src/components/projects/NewProject/Step2.tsx
index 6279a74..c83d956 100644
--- a/src/components/projects/NewProject/Step2.tsx
+++ b/src/components/projects/NewProject/Step2.tsx
@@ -7,25 +7,39 @@ import { useFormik } from "formik";
import * as Yup from "yup";
import RoundedInput from "@/components/elements/RoundedInput";
-import { useState } from "react";
+import { useMemo, useState } from "react";
import Modal from "@/components/elements/Modal";
import useAxios from "@/hooks/useAxios";
import { useRouter } from "next/navigation";
-
-const validationSchema = Yup.object({
- description: Yup.string().required(" وارد کردن توضیحات الزامی است"),
- offerPrice: Yup.string().required(" وارد کردن بودجه الزامی است"),
- projectTime: Yup.string().required(" وارد کردن زمان پیشنهادی الزامی است"),
- projTitle: Yup.string().required(" عنوان پروژه الزامی است"),
- numberOfPerson: Yup.string(),
-});
+import { useTranslation } from "react-i18next";
const Step2 = ({ nextStep }: { nextStep: () => void }) => {
+ const { t } = useTranslation("common");
const { formData, updateForm, isEditing } = useProjectForm();
const [showConfirmModal, setShowConfirmModal] = useState(false);
const { request } = useAxios();
const router = useRouter();
+ const validationSchema = useMemo(
+ () =>
+ Yup.object({
+ description: Yup.string().required(
+ t("projects.newProject.validation.descriptionRequired")
+ ),
+ offerPrice: Yup.string().required(
+ t("projects.newProject.validation.budgetRequired")
+ ),
+ projectTime: Yup.string().required(
+ t("projects.newProject.validation.projectTimeRequired")
+ ),
+ projTitle: Yup.string().required(
+ t("projects.newProject.validation.titleRequired")
+ ),
+ numberOfPerson: Yup.string(),
+ }),
+ [t]
+ );
+
const formik = useFormik({
initialValues: {
description: formData.description || "",
@@ -53,7 +67,6 @@ const Step2 = ({ nextStep }: { nextStep: () => void }) => {
offer_price: values?.offerPrice,
description: values?.description,
project_type: formData?.selectedType,
- // created_for_user: formData?.userId,
public_status: formData?.userId ? "private" : "public",
number_of_person:
values?.numberOfPerson && values?.numberOfPerson.length !== 0
@@ -77,7 +90,7 @@ const Step2 = ({ nextStep }: { nextStep: () => void }) => {
onSubmit={formik.handleSubmit}
className="flex flex-col gap-2 w-full items-center text-sm"
>
- ثبت درخواست
+ {t("projects.newProject.title")}
void }) => {
: "border-gray-300"
}`}
type="text"
- placeholder="عنوان پروژه"
+ placeholder={t("projects.newProject.fields.projectTitle")}
{...formik.getFieldProps("projTitle")}
/>
{formik.touched.projTitle && formik.errors.projTitle && (
@@ -99,10 +112,12 @@ const Step2 = ({ nextStep }: { nextStep: () => void }) => {
: "border-gray-300"
}`}
type="text"
- placeholder="مدت زمان پروژه"
+ placeholder={t("projects.newProject.fields.projectDuration")}
{...formik.getFieldProps("projectTime")}
/>
- (روز)
+
+ ({t("projects.days")})
+
{formik.touched.projectTime && formik.errors.projectTime && (
@@ -115,7 +130,7 @@ const Step2 = ({ nextStep }: { nextStep: () => void }) => {
: "border-gray-300"
}`}
type="number"
- placeholder="بودجه به تومان: 20،000،000 "
+ placeholder={t("projects.newProject.fields.budgetToman")}
{...formik.getFieldProps("offerPrice")}
/>
@@ -125,7 +140,7 @@ const Step2 = ({ nextStep }: { nextStep: () => void }) => {
void }) => {
: "border-gray-300"
}
`}
- placeholder="توضیحات"
+ placeholder={t("projects.newProject.fields.description")}
{...formik.getFieldProps("description")}
>
{formik.touched.description && formik.errors.description && (
{formik.errors.description}
)}
- ثبت و ادامه
+ {t("projects.newProject.submitContinue")}
{showConfirmModal && (
void }) => {
height="280px"
>
-
- شما در هر روز فقط برای ۱ نفر می توانید درخواست رایگان ثبت کنید.
- تعداد نفرات بیشتر از ۱ نفر مشمول هزینه می باشد.
-
+
{t("projects.newProject.dailyLimitModal")}
{
nextStep();
}}
className="bg-sky-200 py-2 px-5 mt-5 rounded-xl min-w-[120px] text-sky-600 text-center"
>
- تایید
+ {t("settings.confirm")}
diff --git a/src/components/projects/NewProject/Step3.tsx b/src/components/projects/NewProject/Step3.tsx
index 15c3ce4..73a9c99 100644
--- a/src/components/projects/NewProject/Step3.tsx
+++ b/src/components/projects/NewProject/Step3.tsx
@@ -6,7 +6,7 @@ import { useProjectForm } from "@/contexts/ProjectFormContext";
import { useFormik } from "formik";
import * as Yup from "yup";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { IProjectType } from "@/types/types";
import { sampleProject } from "@/constants";
@@ -14,12 +14,10 @@ import MainProjectCard from "../MainProjectCard";
import RoundedDiv from "@/components/elements/RoundedDiv";
import { selectionCardClass } from "@/lib/ui/buttonStyles";
import { useRouter } from "next/navigation";
-
-const validationSchema = Yup.object({
- selectedType: Yup.string().required(" انتخاب کردن نوع پروژه الزامی است"),
-});
+import { useTranslation } from "react-i18next";
const Step3 = () => {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const { formData, updateForm } = useProjectForm();
@@ -41,6 +39,26 @@ const Step3 = () => {
fetchStates();
}, []);
+ const validationSchema = useMemo(
+ () =>
+ Yup.object({
+ selectedType: Yup.string().required(
+ t("projects.newProject.validation.typeRequired")
+ ),
+ }),
+ [t]
+ );
+
+ const getDisplayTypeLabel = (name: string) => {
+ if (name === "normal" || name === "free") {
+ return t("projects.newProject.displaySimple");
+ }
+ if (name === "force") {
+ return t("projects.newProject.displayUrgent");
+ }
+ return t("projects.newProject.displayHighlight");
+ };
+
const formik = useFormik({
initialValues: {
selectedType: formData.selectedType || "",
@@ -62,7 +80,6 @@ const Step3 = () => {
offer_price: formData?.offerPrice,
description: formData?.description,
project_type: selectedType,
- // created_for_user: formData?.userId,
public_status: formData?.userId ? "private" : "public",
number_of_person:
formData?.numberOfPerson &&
@@ -84,8 +101,8 @@ const Step3 = () => {
onSubmit={formik.handleSubmit}
className="flex flex-col gap-2 w-full items-center text-sm"
>
- ثبت درخواست
- نوع نمایش درخواست خود برای کاربران را مشخص کنید
+ {t("projects.newProject.title")}
+ {t("projects.newProject.displayTypeHint")}
{typeList?.map((item: IProjectType) => {
return (
{
- {item?.name === "normal"
- ? "نمایش ساده"
- : item?.name === "free"
- ? "نمایش ساده"
- : item?.name === "force"
- ? "نمایش با برچسب فوری"
- : "نمایش با رنگ پس زمینه متفاوت"}
- :
+ {getDisplayTypeLabel(item?.name)}:
{item?.price !== 0
- ? Number(item?.price).toLocaleString() + "تومان"
- : " رایگان"}
+ ? ` ${Number(item?.price).toLocaleString()} ${t("settings.toman")}`
+ : ` ${t("projects.newProject.free")}`}
);
@@ -119,7 +129,7 @@ const Step3 = () => {
{formik.errors.selectedType}
)}
- ثبت درخواست
+ {t("projects.newProject.submitRequest")}
);
diff --git a/src/components/projects/NewProject/step1/AgeSelection.tsx b/src/components/projects/NewProject/step1/AgeSelection.tsx
index 172390c..5afc666 100644
--- a/src/components/projects/NewProject/step1/AgeSelection.tsx
+++ b/src/components/projects/NewProject/step1/AgeSelection.tsx
@@ -1,6 +1,9 @@
+"use client";
+
import RoundedButton from "@/components/elements/RoundedButton";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
+import { useTranslation } from "react-i18next";
interface AgeSelectionProps {
selectedAge: string;
@@ -9,6 +12,13 @@ interface AgeSelectionProps {
}
const AgeSelection = ({ selectedAge, setSelectedAge, setFieldValue }: AgeSelectionProps) => {
+ const { t } = useTranslation("common");
+
+ const getAgeLabel = (age: string) => {
+ if (age === "all") return t("filters.noAgeLimit");
+ return t("filters.yearsOld", { range: age });
+ };
+
return (
{["18-25", "25-30", "all"].map((age) => (
@@ -24,7 +34,7 @@ const AgeSelection = ({ selectedAge, setSelectedAge, setFieldValue }: AgeSelecti
setFieldValue("age", age);
}}
>
- {age === "all" ? "بدون محدودیت" : `${age} سال`}
+ {getAgeLabel(age)}
))}
diff --git a/src/components/projects/NewProject/step1/ExpertiseSelector.tsx b/src/components/projects/NewProject/step1/ExpertiseSelector.tsx
index fdd232e..5055778 100644
--- a/src/components/projects/NewProject/step1/ExpertiseSelector.tsx
+++ b/src/components/projects/NewProject/step1/ExpertiseSelector.tsx
@@ -1,9 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
+"use client";
+
import { IExpertise } from "@/types/types";
import RoundedButton from "@/components/elements/RoundedButton";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { useEffect } from "react";
+import { useTranslation } from "react-i18next";
interface ExpertiseSelectorProps {
expertiseList: IExpertise[] | null;
@@ -24,10 +27,12 @@ const ExpertiseSelector = ({
setFieldValue,
formik,
}: ExpertiseSelectorProps) => {
- const handleButtonPress = (expertise: string) => {
- setExpertise(expertise);
+ const { t } = useTranslation("common");
+
+ const handleButtonPress = (nextExpertise: string) => {
+ setExpertise(nextExpertise);
setSubExpertise([]);
- setFieldValue("expertise", expertise);
+ setFieldValue("expertise", nextExpertise);
setFieldValue("subExpertise", []);
};
@@ -44,39 +49,53 @@ const ExpertiseSelector = ({
setSubExpertise(formik?.values?.subExpertise || []);
}, [formik?.values?.expertise, formik?.values?.subExpertise, setExpertise, setSubExpertise]);
- return (
-
- {expertiseList?.map((item) => (
-
handleButtonPress(item.expertise)}
- type="button"
- >
- {item.expertise}
-
- ))}
+ const subList =
+ expertiseList?.find((item) => item.expertise === expertise)?.sub_expertise ??
+ [];
- {expertiseList
- ?.find((item) => item.expertise === expertise)
- ?.sub_expertise.map((item) => (
+ return (
+
+
+ {t("projects.newProject.mainExpertise")}
+
+
+ {expertiseList?.map((item) => (
{
- handleSubButtonPress(item.name);
- }}
+ onClick={() => handleButtonPress(item.expertise)}
type="button"
>
- {item.name}
+ {item.expertise}
))}
+
+
+ {expertise && subList.length > 0 ? (
+ <>
+
+ {t("projects.newProject.subExpertise")}
+
+
+ {subList.map((item) => (
+ handleSubButtonPress(item.name)}
+ type="button"
+ >
+ {item.name}
+
+ ))}
+
+ >
+ ) : null}
);
};
diff --git a/src/components/projects/NewProject/step1/GenderSelection.tsx b/src/components/projects/NewProject/step1/GenderSelection.tsx
index cdde2c7..b666429 100644
--- a/src/components/projects/NewProject/step1/GenderSelection.tsx
+++ b/src/components/projects/NewProject/step1/GenderSelection.tsx
@@ -1,7 +1,10 @@
+"use client";
+
import RoundedButton from "@/components/elements/RoundedButton";
import Image from "next/image";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
+import { useTranslation } from "react-i18next";
interface GenderSelectionProps {
selectedGender: string;
@@ -14,6 +17,8 @@ const GenderSelection = ({
setSelectedGender,
setFieldValue,
}: GenderSelectionProps) => {
+ const { t } = useTranslation("common");
+
return (
- آقا
+ {t("auth.male")}
- خانم
+ {t("auth.female")}
diff --git a/src/components/projects/NewProject/step1/LocationSelector.tsx b/src/components/projects/NewProject/step1/LocationSelector.tsx
index c78d541..f6b2f5a 100644
--- a/src/components/projects/NewProject/step1/LocationSelector.tsx
+++ b/src/components/projects/NewProject/step1/LocationSelector.tsx
@@ -1,14 +1,18 @@
/* 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 { useTranslation } from "react-i18next";
interface LocationSelectorProps {
formik: any;
}
const LocationSelector: React.FC
= ({ formik }) => {
+ const { t } = useTranslation("common");
const { request } = useAxios();
const [allStates, setAllStates] = useState(null);
const [cities, setCities] = useState(null);
@@ -50,7 +54,7 @@ const LocationSelector: React.FC = ({ formik }) => {
const handleProvinceChange = (e: React.ChangeEvent) => {
const selectedProvinceId = e.target.value;
formik.setFieldValue("stateId", selectedProvinceId);
- fetchCities(selectedProvinceId); // Fetch cities for the selected province
+ fetchCities(selectedProvinceId);
};
return (
@@ -65,7 +69,7 @@ const LocationSelector: React.FC = ({ formik }) => {
onChange={handleProvinceChange}
>
- استان
+ {t("filters.province")}
{allStates?.map((item: IProvince) => (
@@ -89,7 +93,7 @@ const LocationSelector: React.FC = ({ formik }) => {
onChange={(e) => formik.setFieldValue("cityId", e.target.value)}
>
- شهر
+ {t("filters.city")}
{cities?.map((city: ICity) => (
diff --git a/src/components/projects/NewProject/step1/PublicTypeSelection.tsx b/src/components/projects/NewProject/step1/PublicTypeSelection.tsx
index 8e49932..e1ff4c9 100644
--- a/src/components/projects/NewProject/step1/PublicTypeSelection.tsx
+++ b/src/components/projects/NewProject/step1/PublicTypeSelection.tsx
@@ -1,7 +1,10 @@
+"use client";
+
import RoundedButton from "@/components/elements/RoundedButton";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
+import { useTranslation } from "react-i18next";
interface PublicTypeSelectionProps {
selectedPublicType: string;
@@ -14,6 +17,8 @@ const PublicTypeSelection = ({
setSelectedPublicType,
setFieldValue,
}: PublicTypeSelectionProps) => {
+ const { t } = useTranslation("common");
+
return (
{["true", "false"].map((type) => (
@@ -26,7 +31,9 @@ const PublicTypeSelection = ({
setFieldValue("publicType", type);
}}
>
- {type === "true" ? "روابط عمومی بالا" : "بدون محدودیت"}
+ {type === "true"
+ ? t("projects.newProject.highPr")
+ : t("projects.newProject.noPrLimit")}
))}
diff --git a/src/components/projects/ProjectPage/ProjectCreator.tsx b/src/components/projects/ProjectPage/ProjectCreator.tsx
index bca442c..0d05a38 100644
--- a/src/components/projects/ProjectPage/ProjectCreator.tsx
+++ b/src/components/projects/ProjectPage/ProjectCreator.tsx
@@ -1,9 +1,15 @@
+"use client";
+
import { IProjectCreator } from "@/types/types";
import React from "react";
-import ProfileAvatar from "@/components/main/ProfileAvatar";import VerificationBadge from "@/components/main/VerificationBadge";
+import { useTranslation } from "react-i18next";
+import ProfileAvatar from "@/components/main/ProfileAvatar";
+import VerificationBadge from "@/components/main/VerificationBadge";
import Link from "next/link";
function ProjectCreator({ creator }: { creator: IProjectCreator }) {
+ const { t } = useTranslation("common");
+
return (
)}
-
کاربر
+
{t("common.user")}
{creator?.first_name &&
creator?.first_name + " " + creator?.last_name}
diff --git a/src/components/projects/ProjectPage/ProjectRequestForm.tsx b/src/components/projects/ProjectPage/ProjectRequestForm.tsx
index 92b3b20..3573852 100644
--- a/src/components/projects/ProjectPage/ProjectRequestForm.tsx
+++ b/src/components/projects/ProjectPage/ProjectRequestForm.tsx
@@ -1,7 +1,7 @@
"use client";
import RoundedInput from "@/components/elements/RoundedInput";
-import React from "react";
+import React, { useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import RoundedButton from "@/components/elements/RoundedButton";
import { useRouter } from "next/navigation";
@@ -10,11 +10,7 @@ import * as yup from "yup";
import Cookies from "js-cookie";
import toast from "react-hot-toast";
import { useUser } from "@/hooks/useUser";
-
-const schema = yup.object().shape({
- offerPrice: yup.string().required(" وارد کردن مبلغ پیشنهادی الزامی است"),
- projectTime: yup.string().required(" وارد کردن زمان پیشنهادی الزامی است"),
-});
+import { useTranslation } from "react-i18next";
function ProjectRequestForm({
projectId,
@@ -23,6 +19,7 @@ function ProjectRequestForm({
projectId: string;
creatorId?: string;
}) {
+ const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const user = useUser();
@@ -30,6 +27,19 @@ function ProjectRequestForm({
const isOwnProject =
user?._id && creatorId && String(user._id) === String(creatorId);
+ const schema = useMemo(
+ () =>
+ yup.object().shape({
+ offerPrice: yup
+ .string()
+ .required(t("projects.validation.offerPriceRequired")),
+ projectTime: yup
+ .string()
+ .required(t("projects.validation.projectTimeRequired")),
+ }),
+ [t]
+ );
+
const formik = useFormik({
initialValues: {
offerPrice: "",
@@ -38,7 +48,7 @@ function ProjectRequestForm({
validationSchema: schema,
onSubmit: async (values) => {
if (!Cookies.get("token")) {
- toast.error("لطفا ابتدا وارد شوید.");
+ toast.error(t("projects.loginRequired"));
return;
}
try {
@@ -57,7 +67,7 @@ function ProjectRequestForm({
if (isOwnProject) {
return (
- این پروژه متعلق به شماست.
+ {t("projects.ownProject")}
);
}
@@ -70,7 +80,7 @@ function ProjectRequestForm({
)}
- ارسال درخواست
+ {t("projects.submitRequest")}
);
diff --git a/src/components/projects/ProjectPage/ProjectRequests.tsx b/src/components/projects/ProjectPage/ProjectRequests.tsx
index 291b9bf..9d2fd05 100644
--- a/src/components/projects/ProjectPage/ProjectRequests.tsx
+++ b/src/components/projects/ProjectPage/ProjectRequests.tsx
@@ -14,7 +14,11 @@ function ProjectRequests({
? projectRequests.map((item: IProjectRequest) => {
return (
-
+
(false);
@@ -22,18 +24,12 @@ function ProjectsFilter({ expertise }: ProjectsFilterProps) {
const [filterRequestsPrice, setFilterRequestsPrice] = useState("");
const [filterAge, setFilterAge] = useState("");
const [filterGender, setFilterGender] = useState("");
- const [userType, setUserType] = useState