lang
This commit is contained in:
@@ -19,7 +19,9 @@ export async function fetchPosts(
|
||||
feedMode?: "grid" | "reels";
|
||||
seedPostId?: string;
|
||||
sort?: "latest";
|
||||
reelsTab?: "for_you" | "following" | "saved";
|
||||
reelsTab?: "for_you" | "following" | "saved" | "near_me";
|
||||
lat?: string;
|
||||
lng?: string;
|
||||
heightMin?: string;
|
||||
heightMax?: string;
|
||||
weightMin?: string;
|
||||
|
||||
@@ -15,6 +15,7 @@ export type StoryItem = {
|
||||
_id: string;
|
||||
media_path: string;
|
||||
media_type: "image" | "video";
|
||||
linked_post_id?: string | null;
|
||||
overlays?: StoryTextOverlay[];
|
||||
createdAt?: string;
|
||||
expires_at?: string;
|
||||
@@ -33,6 +34,7 @@ export type StoryFeedUser = {
|
||||
};
|
||||
stories: StoryItem[];
|
||||
has_unviewed: boolean;
|
||||
is_following?: boolean;
|
||||
};
|
||||
|
||||
export type StoriesFeedResponse = {
|
||||
@@ -166,3 +168,21 @@ export async function fetchStoryViewers(
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function sharePostToStory(
|
||||
postId: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${getApiBaseUrl()}/stories/share-post`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ postId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
throw new Error(json?.message || "خطا در انتشار استوری");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,30 +12,11 @@ import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
const schema = yup.object().shape({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, "کد وارد شده صحیح نیست")
|
||||
.required("کد تایید را وارد کنید"),
|
||||
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 ChangePassword() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [otpError, setOtpError] = useState(false);
|
||||
@@ -56,6 +37,29 @@ function ChangePassword() {
|
||||
);
|
||||
const isEmailReset = resetChannel === "email";
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object().shape({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, t("auth.otpInvalid"))
|
||||
.required(t("auth.otpRequired")),
|
||||
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: {
|
||||
otp: "",
|
||||
@@ -65,7 +69,7 @@ function ChangePassword() {
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values) => {
|
||||
if (!resetIdentifier) {
|
||||
toast.error("ابتدا از صفحه فراموشی رمز، کد دریافت کنید");
|
||||
toast.error(t("auth.getResetCodeFirst"));
|
||||
router.push("/forget-password");
|
||||
return;
|
||||
}
|
||||
@@ -81,107 +85,109 @@ function ChangePassword() {
|
||||
});
|
||||
localStorage.removeItem("reset_identifier");
|
||||
localStorage.removeItem("reset_channel");
|
||||
toast.success("کلمه عبور با موفقیت تغییر کرد");
|
||||
toast.success(t("auth.passwordChangedSuccess"));
|
||||
router.push("/login-with-username");
|
||||
} catch (err: unknown) {
|
||||
setOtpError(true);
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || "تغییر کلمه عبور ناموفق بود";
|
||||
?.message || t("auth.changePasswordFailed");
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="تغییر کلمه عبور" />
|
||||
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
|
||||
{isEmailReset
|
||||
? "کد ارسالشده به ایمیل و کلمه عبور جدید را وارد کنید."
|
||||
: "کد ارسالشده به موبایل و کلمه عبور جدید را وارد کنید."}
|
||||
</p>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
}}
|
||||
error={
|
||||
otpError || Boolean(formik.touched.otp && formik.errors.otp)
|
||||
}
|
||||
disabled={loading}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
placeholder="کلمه عبور جدید"
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.password && formik.errors.password
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.password}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.password}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthPasswordInput
|
||||
name="confirmPassword"
|
||||
placeholder="تکرار کلمه عبور"
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.confirmPassword}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.confirmPassword}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading || formik.values.otp.length !== 6}
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={t("auth.changePasswordTitle")} />
|
||||
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
|
||||
{isEmailReset
|
||||
? t("auth.changePasswordEmailHint")
|
||||
: t("auth.changePasswordMobileHint")}
|
||||
</p>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
تغییر کلمه عبور
|
||||
</AuthButton>
|
||||
</form>
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
}}
|
||||
error={
|
||||
otpError || Boolean(formik.touched.otp && formik.errors.otp)
|
||||
}
|
||||
disabled={loading}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
|
||||
<Link href="/forget-password">
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
ارسال مجدد کد
|
||||
</small>
|
||||
</Link>
|
||||
<Link href="/login-with-username">
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-4 block">
|
||||
بازگشت به ورود
|
||||
</small>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
placeholder={t("auth.newPasswordPlaceholder")}
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.password && formik.errors.password
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.password}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.password}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthPasswordInput
|
||||
name="confirmPassword"
|
||||
placeholder={t("auth.confirmPasswordPlaceholder")}
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.confirmPassword}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.confirmPassword}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading || formik.values.otp.length !== 6}
|
||||
>
|
||||
{t("auth.changePassword")}
|
||||
</AuthButton>
|
||||
</form>
|
||||
|
||||
<Link href="/forget-password">
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
{t("auth.resendCodeLink")}
|
||||
</small>
|
||||
</Link>
|
||||
<Link href="/login-with-username">
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-4 block">
|
||||
{t("auth.backToLogin")}
|
||||
</small>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import AuthHead from "@/components/auth/AuthHead";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import AuthOtpInput from "@/components/auth/AuthOtpInput";
|
||||
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -16,16 +16,11 @@ import { useRouter } from "next/navigation";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import { setAuthSession } from "@/lib/auth/session";
|
||||
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, "کد وارد شده صحیح نیست")
|
||||
.required("کد تایید را وارد کنید"),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function ForgetPasswordOtp() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [otpExpired, setOtpExpired] = useState(false);
|
||||
@@ -35,6 +30,18 @@ function ForgetPasswordOtp() {
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, t("auth.otpInvalid"))
|
||||
.required(t("auth.otpRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
otp: "",
|
||||
@@ -67,67 +74,75 @@ function ForgetPasswordOtp() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="فراموش کردن کلمه عبور" />
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/login", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={t("auth.forgetPasswordTitle")} />
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/login", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
success={isSuccess}
|
||||
disabled={loading || otpExpired || isSuccess}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
تایید کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/login-with-username"}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
با نام کاربری و کلمه عبور خود وارد شوید
|
||||
</small>
|
||||
</Link>
|
||||
<Link href={"/forget-password"}>
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
اصلاح شماره موبایل
|
||||
</small>
|
||||
</Link>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
}}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
success={isSuccess}
|
||||
disabled={loading || otpExpired || isSuccess}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={
|
||||
loading ||
|
||||
otpExpired ||
|
||||
isSuccess ||
|
||||
formik.values.otp.length !== 6
|
||||
}
|
||||
>
|
||||
{t("auth.verifyCode")}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/login-with-username"}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
{t("auth.loginWithUsername")}
|
||||
</small>
|
||||
</Link>
|
||||
<Link href={"/forget-password"}>
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
{t("auth.editMobile")}
|
||||
</small>
|
||||
</Link>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,32 +5,39 @@ import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import Link from "next/link";
|
||||
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 toast from "react-hot-toast";
|
||||
|
||||
const schema = yup.object({
|
||||
identifier: yup
|
||||
.string()
|
||||
.required("ایمیل یا شماره موبایل الزامی است")
|
||||
.test("identifier", "ایمیل یا شماره موبایل معتبر نیست", (value) => {
|
||||
if (!value) return false;
|
||||
const trimmed = value.trim();
|
||||
return (
|
||||
/^(09\d{9})$/.test(trimmed) ||
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)
|
||||
);
|
||||
}),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function ForgetPasswordPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [destination, setDestination] = useState("");
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object({
|
||||
identifier: yup
|
||||
.string()
|
||||
.required(t("auth.identifierRequired"))
|
||||
.test("identifier", t("auth.identifierInvalid"), (value) => {
|
||||
if (!value) return false;
|
||||
const trimmed = value.trim();
|
||||
return (
|
||||
/^(09\d{9})$/.test(trimmed) ||
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)
|
||||
);
|
||||
}),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: { identifier: "" },
|
||||
validationSchema: schema,
|
||||
@@ -55,69 +62,70 @@ function ForgetPasswordPage() {
|
||||
setDestination(response.destination);
|
||||
}
|
||||
|
||||
toast.success(response.message || "کد بازیابی ارسال شد");
|
||||
toast.success(response.message || t("auth.resetCodeSentDefault"));
|
||||
router.push("/change-passowrd");
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || "ارسال کد ناموفق بود";
|
||||
?.message || t("auth.sendCodeFailed");
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="فراموشی کلمه عبور" />
|
||||
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
|
||||
ایمیل (برای حساب گوگل) یا شماره موبایل خود را وارد کنید تا کد بازیابی
|
||||
ارسال شود.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
name="identifier"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder="email@example.com یا 09xxxxxxxxx"
|
||||
className={`border ${
|
||||
formik.touched.identifier && formik.errors.identifier
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.identifier}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.identifier && formik.errors.identifier && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.identifier}
|
||||
</small>
|
||||
)}
|
||||
{destination && (
|
||||
<small className="text-green-600 mt-2 block text-center">
|
||||
کد به {destination} ارسال شد
|
||||
</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={t("auth.forgetPasswordTitle")} />
|
||||
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
|
||||
{t("auth.forgetPasswordDesc")}
|
||||
</p>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
ارسال کد بازیابی
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href="/login-with-username">
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
بازگشت به ورود
|
||||
</small>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
<AuthInput
|
||||
name="identifier"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder={t("auth.identifierPlaceholder")}
|
||||
className={`border ${
|
||||
formik.touched.identifier && formik.errors.identifier
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.identifier}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.identifier && formik.errors.identifier && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.identifier}
|
||||
</small>
|
||||
)}
|
||||
{destination && (
|
||||
<small className="text-green-600 mt-2 block text-center">
|
||||
{t("auth.codeSentTo", { destination })}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("auth.sendResetCode")}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href="/login-with-username">
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
{t("auth.backToLogin")}
|
||||
</small>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
85
src/app/(auth)/(login)/login-2fa/page.tsx
Normal file
85
src/app/(auth)/(login)/login-2fa/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthOtpInput from "@/components/auth/AuthOtpInput";
|
||||
import AuthButton from "@/components/auth/AuthButton";
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { completeLogin, getSafeRedirectPath } from "@/lib/auth/postLogin";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function LoginTwoFactorPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [code, setCode] = useState("");
|
||||
const [tempToken, setTempToken] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const token =
|
||||
typeof window !== "undefined"
|
||||
? sessionStorage.getItem("login_2fa_temp")
|
||||
: null;
|
||||
if (!token) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
setTempToken(token);
|
||||
}, [router]);
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!tempToken || code.length !== 6) return;
|
||||
|
||||
try {
|
||||
const response = (await request("POST", "/auth/totp/verify-login", {
|
||||
temp_token: tempToken,
|
||||
code,
|
||||
})) as IVerifyOtp;
|
||||
|
||||
sessionStorage.removeItem("login_2fa_temp");
|
||||
|
||||
const loggedIn = await completeLogin(router, response, {
|
||||
redirectTo: getSafeRedirectPath(),
|
||||
});
|
||||
|
||||
if (!loggedIn) {
|
||||
toast.error(t("auth.invalidServerResponse"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("settings.edit.twoFactor.invalidCode"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="flex h-screen flex-col items-center justify-center p-4">
|
||||
<AuthHead title={t("auth.twoFactorTitle")} />
|
||||
<p className="mb-6 max-w-sm text-center text-sm text-neutral-500">
|
||||
{t("auth.twoFactorHint")}
|
||||
</p>
|
||||
<AuthOtpInput
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
onComplete={() => setTimeout(handleVerify, 300)}
|
||||
disabled={loading || !tempToken}
|
||||
/>
|
||||
<AuthButton
|
||||
type="button"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading || code.length !== 6 || !tempToken}
|
||||
onClick={handleVerify}
|
||||
>
|
||||
{t("auth.verifyCode")}
|
||||
</AuthButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
@@ -21,16 +21,11 @@ import {
|
||||
} from "@/lib/auth/postLogin";
|
||||
import toast from "react-hot-toast";
|
||||
import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect";
|
||||
|
||||
const schema = yup.object().shape({
|
||||
username: yup.string().required("نام کاربری الزامی است"),
|
||||
password: yup
|
||||
.string()
|
||||
.required("کلمه عبور نمی\u200Cتواند خالی باشد")
|
||||
.min(8, "کلمه عبور نباید کوتاه تر از 8 کاراکتر باشد"),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function LoginWithUsername() {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
@@ -40,6 +35,18 @@ function LoginWithUsername() {
|
||||
const redirectPath = getSafeRedirectPath();
|
||||
useAuthSessionRedirect();
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object().shape({
|
||||
username: yup.string().required(t("auth.usernameRequired")),
|
||||
password: yup
|
||||
.string()
|
||||
.required(t("auth.passwordRequired"))
|
||||
.min(8, t("auth.passwordMin")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
username: "",
|
||||
@@ -66,7 +73,7 @@ function LoginWithUsername() {
|
||||
});
|
||||
|
||||
if (!loggedIn) {
|
||||
const message = "پاسخ سرور نامعتبر بود. دوباره تلاش کنید.";
|
||||
const message = t("auth.invalidServerResponse");
|
||||
setAuthError(true);
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
@@ -75,7 +82,10 @@ function LoginWithUsername() {
|
||||
|
||||
setIsSuccess(true);
|
||||
} catch (err: any) {
|
||||
const message = getAxiosErrorMessage(err);
|
||||
const message = getAxiosErrorMessage(
|
||||
err,
|
||||
i18n.language === "en" ? "en" : "fa"
|
||||
);
|
||||
setAuthError(true);
|
||||
setIsSuccess(false);
|
||||
setErrorMessage(message);
|
||||
@@ -85,88 +95,91 @@ function LoginWithUsername() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="ورود" />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="نام کاربری"
|
||||
success={isSuccess}
|
||||
error={
|
||||
authError ||
|
||||
Boolean(formik.touched.username && formik.errors.username)
|
||||
}
|
||||
value={formik.values.username}
|
||||
onChange={(e) => {
|
||||
setAuthError(false);
|
||||
setErrorMessage("");
|
||||
setIsSuccess(false);
|
||||
formik.handleChange(e);
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={loading || isSuccess}
|
||||
/>
|
||||
{formik.touched.username && formik.errors.username && !authError && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.username}
|
||||
</small>
|
||||
)}
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
placeholder="کلمه عبور"
|
||||
wrapperClassName="mt-4"
|
||||
success={isSuccess}
|
||||
error={
|
||||
authError ||
|
||||
Boolean(formik.touched.password && formik.errors.password)
|
||||
}
|
||||
value={formik.values.password}
|
||||
onChange={(e) => {
|
||||
setAuthError(false);
|
||||
setErrorMessage("");
|
||||
setIsSuccess(false);
|
||||
formik.handleChange(e);
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={loading || isSuccess}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && !authError && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.password}
|
||||
</small>
|
||||
)}
|
||||
{authError && errorMessage && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{errorMessage}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading || isSuccess}
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={t("auth.login")} />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
ورود
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/forget-password"}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
کلمه عبور خود را فراموش کرده اید؟
|
||||
</small>
|
||||
</Link>
|
||||
<Link href={"/register"}>
|
||||
<small className="text-[#292D32] font-bold text-xs mt-8 block">
|
||||
حساب کاربری ندارید؟ <span className="text-[#0033EA]">ثبت نام</span>
|
||||
</small>
|
||||
</Link>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
<AuthInput
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={t("auth.usernamePlaceholder")}
|
||||
success={isSuccess}
|
||||
error={
|
||||
authError ||
|
||||
Boolean(formik.touched.username && formik.errors.username)
|
||||
}
|
||||
value={formik.values.username}
|
||||
onChange={(e) => {
|
||||
setAuthError(false);
|
||||
setErrorMessage("");
|
||||
setIsSuccess(false);
|
||||
formik.handleChange(e);
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={loading || isSuccess}
|
||||
/>
|
||||
{formik.touched.username && formik.errors.username && !authError && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.username}
|
||||
</small>
|
||||
)}
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
placeholder={t("auth.passwordPlaceholder")}
|
||||
wrapperClassName="mt-4"
|
||||
success={isSuccess}
|
||||
error={
|
||||
authError ||
|
||||
Boolean(formik.touched.password && formik.errors.password)
|
||||
}
|
||||
value={formik.values.password}
|
||||
onChange={(e) => {
|
||||
setAuthError(false);
|
||||
setErrorMessage("");
|
||||
setIsSuccess(false);
|
||||
formik.handleChange(e);
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={loading || isSuccess}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && !authError && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.password}
|
||||
</small>
|
||||
)}
|
||||
{authError && errorMessage && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{errorMessage}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading || isSuccess}
|
||||
>
|
||||
{t("auth.login")}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/forget-password"}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
{t("auth.forgotPassword")}
|
||||
</small>
|
||||
</Link>
|
||||
<Link href={"/register"}>
|
||||
<small className="text-[#292D32] font-bold text-xs mt-8 block">
|
||||
{t("auth.noAccount")}{" "}
|
||||
<span className="text-[#0033EA]">{t("auth.signUp")}</span>
|
||||
</small>
|
||||
</Link>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,27 +6,21 @@ import AuthInput from "@/components/auth/AuthInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
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";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
mobile: yup
|
||||
.string()
|
||||
.matches(/^(09\d{9})$/, "شماره موبایل معتبر نیست")
|
||||
.required("شماره موبایل الزامی است"),
|
||||
});
|
||||
|
||||
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
|
||||
import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
|
||||
import { GoogleSignInButton } from "@/app/(auth)/AuthProviders";
|
||||
import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps";
|
||||
import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function Login() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
@@ -37,6 +31,17 @@ function Login() {
|
||||
? `/login-with-username?redirect=${encodeURIComponent(redirectPath)}`
|
||||
: "/login-with-username";
|
||||
|
||||
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: "",
|
||||
@@ -55,51 +60,53 @@ function Login() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="ورود" />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
name="mobile"
|
||||
maxLength={11}
|
||||
{...MOBILE_NUMERIC_INPUT_PROPS}
|
||||
dir="ltr"
|
||||
placeholder="09 _ _ _ _ _ _ _ _ _"
|
||||
className={`border ${
|
||||
formik.touched.mobile && formik.errors.mobile
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.mobile}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.mobile && formik.errors.mobile && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.mobile}
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={t("auth.login")} />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
name="mobile"
|
||||
maxLength={11}
|
||||
{...MOBILE_NUMERIC_INPUT_PROPS}
|
||||
dir="ltr"
|
||||
placeholder="09 _ _ _ _ _ _ _ _ _"
|
||||
className={`border ${
|
||||
formik.touched.mobile && formik.errors.mobile
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.mobile}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.mobile && formik.errors.mobile && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.mobile}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
|
||||
{t("auth.sendCode")}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<GoogleSignInButton mode="login" />
|
||||
<Link href={usernameLoginHref}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
{t("auth.loginWithUsername")}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
|
||||
ارسال کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<GoogleSignInButton mode="login" />
|
||||
<Link href={usernameLoginHref}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
با نام کاربری و کلمه عبور خود وارد شوید
|
||||
</small>
|
||||
</Link>
|
||||
<button onClick={() => setModalOpen(true)} className="mb-2">
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
قوانین و مقررات
|
||||
</small>
|
||||
</button>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</Link>
|
||||
<button onClick={() => setModalOpen(true)} className="mb-2">
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
{t("auth.rules")}
|
||||
</small>
|
||||
</button>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import AuthHead from "@/components/auth/AuthHead";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import AuthOtpInput from "@/components/auth/AuthOtpInput";
|
||||
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -18,16 +18,11 @@ import {
|
||||
completeLogin,
|
||||
getSafeRedirectPath,
|
||||
} from "@/lib/auth/postLogin";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, "کد وارد شده صحیح نیست")
|
||||
.required("کد تایید را وارد کنید"),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function VerifyOtp() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [otpExpired, setOtpExpired] = useState(false);
|
||||
@@ -37,6 +32,18 @@ function VerifyOtp() {
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, t("auth.otpInvalid"))
|
||||
.required(t("auth.otpRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
otp: "",
|
||||
@@ -56,21 +63,13 @@ function VerifyOtp() {
|
||||
otp: values.otp.trim(),
|
||||
})) as IVerifyOtp;
|
||||
|
||||
switch (response?.page) {
|
||||
case "home":
|
||||
setIsSuccess(true);
|
||||
await completeLogin(router, response, {
|
||||
redirectTo: getSafeRedirectPath(),
|
||||
});
|
||||
break;
|
||||
case "auth-page":
|
||||
setIsSuccess(true);
|
||||
await completeLogin(router, response);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
const loggedIn = await completeLogin(router, response, {
|
||||
redirectTo: getSafeRedirectPath(),
|
||||
});
|
||||
|
||||
if (loggedIn) {
|
||||
setIsSuccess(true);
|
||||
}
|
||||
// router.push("/dashboard");
|
||||
} catch (err: any) {
|
||||
setOtpError(true);
|
||||
setIsSuccess(false);
|
||||
@@ -80,68 +79,76 @@ function VerifyOtp() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="ورود" />
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/login", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={t("auth.login")} />
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/login", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
success={isSuccess}
|
||||
disabled={loading || otpExpired || isSuccess}
|
||||
/>
|
||||
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
تایید کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/login-with-username"}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
با نام کاربری و کلمه عبور خود وارد شوید
|
||||
</small>
|
||||
</Link>
|
||||
<Link href={"/login"}>
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
اصلاح شماره موبایل
|
||||
</small>
|
||||
</Link>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
}}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
success={isSuccess}
|
||||
disabled={loading || otpExpired || isSuccess}
|
||||
/>
|
||||
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={
|
||||
loading ||
|
||||
otpExpired ||
|
||||
isSuccess ||
|
||||
formik.values.otp.length !== 6
|
||||
}
|
||||
>
|
||||
{t("auth.verifyCode")}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/login-with-username"}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
{t("auth.loginWithUsername")}
|
||||
</small>
|
||||
</Link>
|
||||
<Link href={"/login"}>
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
{t("auth.editMobile")}
|
||||
</small>
|
||||
</Link>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import AuthHead from "@/components/auth/AuthHead";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import AuthOtpInput from "@/components/auth/AuthOtpInput";
|
||||
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -16,16 +16,11 @@ import * as yup from "yup";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { completeLogin } from "@/lib/auth/postLogin";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, "کد وارد شده صحیح نیست")
|
||||
.required("کد تایید را وارد کنید"),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function RegisterOtp() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [otpExpired, setOtpExpired] = useState(false);
|
||||
@@ -35,6 +30,18 @@ function RegisterOtp() {
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, t("auth.otpInvalid"))
|
||||
.required(t("auth.otpRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
otp: "",
|
||||
@@ -65,62 +72,70 @@ function RegisterOtp() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="ثبت نام" />
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/register", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={t("auth.register")} />
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/register", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
success={isSuccess}
|
||||
disabled={loading || otpExpired || isSuccess}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
تایید کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/forget-password"}>
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
اصلاح شماره موبایل
|
||||
</small>
|
||||
</Link>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
}}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
success={isSuccess}
|
||||
disabled={loading || otpExpired || isSuccess}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={
|
||||
loading ||
|
||||
otpExpired ||
|
||||
isSuccess ||
|
||||
formik.values.otp.length !== 6
|
||||
}
|
||||
>
|
||||
{t("auth.verifyCode")}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/forget-password"}>
|
||||
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
|
||||
{t("auth.editMobile")}
|
||||
</small>
|
||||
</Link>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,11 @@ import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function RegisterCompletePage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [action, setAction] = useState<"app" | "continue" | null>(null);
|
||||
@@ -32,47 +35,50 @@ function RegisterCompletePage() {
|
||||
router.push(getSafeRedirectPath());
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || "خطا در تکمیل ثبتنام");
|
||||
toast.error(
|
||||
err?.response?.data?.message || t("auth.registrationFinishFailed")
|
||||
);
|
||||
} finally {
|
||||
setAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<div className="flex w-full flex-1 flex-col items-center">
|
||||
<AuthPageContent>
|
||||
<AuthHead title="ثبتنام اولیه تکمیل شد" />
|
||||
<p className="text-sm text-gray-500 text-center max-w-sm mt-2">
|
||||
میتوانید وارد برنامه شوید یا ثبتنام کامل (تایید هویت و پروفایل)
|
||||
را ادامه دهید.
|
||||
</p>
|
||||
</AuthPageContent>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<div className="flex w-full flex-1 flex-col items-center">
|
||||
<AuthPageContent>
|
||||
<AuthHead title={t("auth.registerCompleteTitle")} />
|
||||
<p className="text-sm text-gray-500 text-center max-w-sm mt-2">
|
||||
{t("auth.registerCompleteDesc")}
|
||||
</p>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter>
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm justify-center items-center">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("continue")}
|
||||
className=" w-full sm:w-auto"
|
||||
disabled={loading}
|
||||
loading={loading && action === "continue"}
|
||||
>
|
||||
ادامه ثبت نام
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("app")}
|
||||
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
|
||||
disabled={loading}
|
||||
loading={loading && action === "app"}
|
||||
>
|
||||
ورود به برنامه
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</AuthFormFooter>
|
||||
</div>
|
||||
</AuthPageLayout>
|
||||
<AuthFormFooter>
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm justify-center items-center">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("continue")}
|
||||
className=" w-full sm:w-auto"
|
||||
disabled={loading}
|
||||
loading={loading && action === "continue"}
|
||||
>
|
||||
{t("auth.continueRegistration")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("app")}
|
||||
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
|
||||
disabled={loading}
|
||||
loading={loading && action === "app"}
|
||||
>
|
||||
{t("auth.enterApp")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</AuthFormFooter>
|
||||
</div>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ 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";
|
||||
@@ -15,13 +15,11 @@ import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
|
||||
|
||||
const schema = yup.object().shape({
|
||||
name: yup.string().trim().required("نام الزامی است").min(2, "نام کوتاه است"),
|
||||
family: yup.string().trim().optional(),
|
||||
});
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
function FullNamePage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [nameSaved, setNameSaved] = useState(false);
|
||||
@@ -29,6 +27,19 @@ function FullNamePage() {
|
||||
null
|
||||
);
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
yup.object().shape({
|
||||
name: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(t("auth.nameRequired"))
|
||||
.min(2, t("auth.nameTooShort")),
|
||||
family: yup.string().trim().optional(),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
family: "",
|
||||
@@ -50,9 +61,9 @@ function FullNamePage() {
|
||||
}
|
||||
|
||||
setNameSaved(true);
|
||||
toast.success("نام با موفقیت ثبت شد");
|
||||
toast.success(t("auth.nameSavedSuccess"));
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || "خطا در ذخیره نام");
|
||||
toast.error(err?.response?.data?.message || t("auth.saveNameFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -73,105 +84,109 @@ function FullNamePage() {
|
||||
router.push(getSafeRedirectPath());
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || "خطا در تکمیل ثبتنام");
|
||||
toast.error(
|
||||
err?.response?.data?.message || t("auth.registrationFinishFailed")
|
||||
);
|
||||
} finally {
|
||||
setChoiceLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<AuthHead title="نام و نام خانوادگی" />
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<AuthInput
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="نام *"
|
||||
dir="rtl"
|
||||
disabled={nameSaved || loading}
|
||||
className={`border mt-4 ${
|
||||
formik.touched.name && formik.errors.name
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.name}
|
||||
</small>
|
||||
)}
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<AuthHead title={t("auth.fullNameTitle")} />
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<AuthInput
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder={t("auth.firstNamePlaceholder")}
|
||||
dir="rtl"
|
||||
disabled={nameSaved || loading}
|
||||
className={`border mt-4 ${
|
||||
formik.touched.name && formik.errors.name
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.name}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthInput
|
||||
name="family"
|
||||
type="text"
|
||||
placeholder="نام خانوادگی (اختیاری)"
|
||||
dir="rtl"
|
||||
disabled={nameSaved || loading}
|
||||
className={`border mt-4 ${
|
||||
formik.touched.family && formik.errors.family
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.family}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.family && formik.errors.family && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.family}
|
||||
</small>
|
||||
)}
|
||||
<AuthInput
|
||||
name="family"
|
||||
type="text"
|
||||
placeholder={t("auth.lastNamePlaceholder")}
|
||||
dir="rtl"
|
||||
disabled={nameSaved || loading}
|
||||
className={`border mt-4 ${
|
||||
formik.touched.family && formik.errors.family
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.family}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.family && formik.errors.family && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.family}
|
||||
</small>
|
||||
)}
|
||||
|
||||
{nameSaved && (
|
||||
<p className="text-sm text-green-600 text-center mt-4">
|
||||
نام شما ثبت شد. یکی از گزینههای زیر را انتخاب کنید.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter>
|
||||
{!nameSaved ? (
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
disabled={loading || !formik.values.name.trim()}
|
||||
loading={loading}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm justify-center items-center">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("continue")}
|
||||
className=" w-full sm:w-auto"
|
||||
disabled={Boolean(choiceLoading)}
|
||||
loading={choiceLoading === "continue"}
|
||||
>
|
||||
ادامه ثبت نام
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("app")}
|
||||
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
|
||||
disabled={Boolean(choiceLoading)}
|
||||
loading={choiceLoading === "app"}
|
||||
>
|
||||
ورود به برنامه
|
||||
</AuthNextButton>
|
||||
{nameSaved && (
|
||||
<p className="text-sm text-green-600 text-center mt-4">
|
||||
{t("auth.nameSavedChoose")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter>
|
||||
{!nameSaved ? (
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
disabled={loading || !formik.values.name.trim()}
|
||||
loading={loading}
|
||||
>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm justify-center items-center">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("continue")}
|
||||
className=" w-full sm:w-auto"
|
||||
disabled={Boolean(choiceLoading)}
|
||||
loading={choiceLoading === "continue"}
|
||||
>
|
||||
{t("auth.continueRegistration")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("app")}
|
||||
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
|
||||
disabled={Boolean(choiceLoading)}
|
||||
loading={choiceLoading === "app"}
|
||||
>
|
||||
{t("auth.enterApp")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
)}
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
15
src/app/(auth)/(register)/register/loading.tsx
Normal file
15
src/app/(auth)/(register)/register/loading.tsx
Normal file
@@ -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 (
|
||||
<div className="mx-auto flex min-h-[50vh] w-full max-w-xl flex-col items-center justify-center gap-3 px-4">
|
||||
<IOSSpinner size={28} color="#ff107d" />
|
||||
<span className="text-sm text-neutral-500">{t("auth.pageLoading")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="ثبت نام" />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
name="mobile"
|
||||
maxLength={11}
|
||||
{...MOBILE_NUMERIC_INPUT_PROPS}
|
||||
dir="ltr"
|
||||
placeholder="09 _ _ _ _ _ _ _ _ _"
|
||||
className={`border ${
|
||||
formik.touched.mobile && formik.errors.mobile
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.mobile}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.mobile && formik.errors.mobile && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.mobile}
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={t("auth.register")} />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
name="mobile"
|
||||
maxLength={11}
|
||||
{...MOBILE_NUMERIC_INPUT_PROPS}
|
||||
dir="ltr"
|
||||
placeholder="09 _ _ _ _ _ _ _ _ _"
|
||||
className={`border ${
|
||||
formik.touched.mobile && formik.errors.mobile
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.mobile}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.mobile && formik.errors.mobile && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.mobile}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("auth.sendCode")}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<GoogleSignInButton mode="register" />
|
||||
<button onClick={() => setModalOpen(true)} className="mb-2">
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
{t("auth.rules")}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
|
||||
ارسال کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<GoogleSignInButton mode="register" />
|
||||
<button onClick={() => setModalOpen(true)} className="mb-2">
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
قوانین و مقررات
|
||||
</small>
|
||||
</button>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</button>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<AuthHead title="کلمه عبور" />
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
placeholder="کلمه عبور"
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.password && formik.errors.password
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.password}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.password}
|
||||
</small>
|
||||
)}
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<AuthHead title={t("auth.passwordTitle")} />
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
placeholder={t("auth.passwordPlaceholder")}
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.password && formik.errors.password
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.password}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.password}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthPasswordInput
|
||||
name="confirmPassword"
|
||||
placeholder="تکرار کلمه عبور"
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.confirmPassword}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.confirmPassword}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
<AuthPasswordInput
|
||||
name="confirmPassword"
|
||||
placeholder={t("auth.confirmPasswordPlaceholder")}
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.confirmPassword}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.confirmPassword &&
|
||||
formik.errors.confirmPassword && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.confirmPassword}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<AuthHead title="نام کاربری" />
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<p className="text-xs text-gray-500 text-center mb-3 max-w-[290px]">
|
||||
حداقل {USERNAME_MIN_LENGTH} کاراکتر؛ فقط حروف انگلیسی، اعداد و
|
||||
. _ -
|
||||
</p>
|
||||
<AuthInput
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="نام کاربری"
|
||||
dir="ltr"
|
||||
autoComplete="username"
|
||||
spellCheck={false}
|
||||
className={`border ${
|
||||
(formik.touched.username && formik.errors.username) || isDuplicate
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.username}
|
||||
onChange={(e) => handleUsernameChange(e.target.value)}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<AuthHead title={t("auth.usernameTitle")} />
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<p className="text-xs text-gray-500 text-center mb-3 max-w-[290px]">
|
||||
{t("auth.usernameHint", {
|
||||
max: USERNAME_MAX_LENGTH,
|
||||
min: USERNAME_MIN_LENGTH,
|
||||
})}
|
||||
</p>
|
||||
<AuthInput
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={t("auth.usernamePlaceholder")}
|
||||
dir="ltr"
|
||||
autoComplete="username"
|
||||
spellCheck={false}
|
||||
maxLength={USERNAME_MAX_LENGTH}
|
||||
className={`border ${
|
||||
(formik.touched.username && formik.errors.username) ||
|
||||
isDuplicate
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.username}
|
||||
onChange={(e) => handleUsernameChange(e.target.value)}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
|
||||
{isDuplicate && (
|
||||
<small className="text-red-500 mt-2 block text-center font-medium">
|
||||
نام کاربری تکراری است
|
||||
</small>
|
||||
)}
|
||||
{isDuplicate && (
|
||||
<small className="text-red-500 mt-2 block text-center font-medium">
|
||||
{t("auth.usernameDuplicate")}
|
||||
</small>
|
||||
)}
|
||||
|
||||
{formik.touched.username && formik.errors.username && !isDuplicate && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.username}
|
||||
</small>
|
||||
)}
|
||||
{formik.touched.username &&
|
||||
formik.errors.username &&
|
||||
!isDuplicate && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.username}
|
||||
</small>
|
||||
)}
|
||||
|
||||
{!formik.errors.username && !isDuplicate && (
|
||||
<small className="text-gray-400 mt-2 block text-center text-xs">
|
||||
{USERNAME_VALIDATION_MESSAGE}
|
||||
</small>
|
||||
)}
|
||||
{!formik.errors.username && !isDuplicate && (
|
||||
<small className="text-gray-400 mt-2 block text-center text-xs">
|
||||
{t("auth.usernameCharsRule")}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<UsernameSuggestions
|
||||
suggestions={suggestions}
|
||||
onSelect={handleSuggestionSelect}
|
||||
/>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
<UsernameSuggestions
|
||||
suggestions={suggestions}
|
||||
onSelect={handleSuggestionSelect}
|
||||
/>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={
|
||||
loading || formik.values.username.length < USERNAME_MIN_LENGTH
|
||||
}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={
|
||||
loading || formik.values.username.length < USERNAME_MIN_LENGTH
|
||||
}
|
||||
>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<React.ComponentProps<typeof GoogleSignInButtonBase>, "clientId">
|
||||
) {
|
||||
const clientId = useGoogleClientId();
|
||||
return (
|
||||
<GoogleSignInButtonBase
|
||||
{...props}
|
||||
clientId={clientId || GOOGLE_OAUTH_CLIENT_ID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<GoogleAuthRootProvider>
|
||||
<RegisterRouteGuard>{children}</RegisterRouteGuard>
|
||||
</GoogleAuthRootProvider>
|
||||
<GoogleClientIdContext.Provider value={resolvedClientId}>
|
||||
<GoogleAuthRootProvider clientId={resolvedClientId}>
|
||||
<RegisterRouteGuard>{children}</RegisterRouteGuard>
|
||||
</GoogleAuthRootProvider>
|
||||
</GoogleClientIdContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Metadata> {
|
||||
return generateSeoPageMetadata("auth", { index: false });
|
||||
}
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <AuthProviders>{children}</AuthProviders>;
|
||||
const googleClientId = getGoogleClientId();
|
||||
|
||||
return (
|
||||
<AuthProviders googleClientId={googleClientId}>{children}</AuthProviders>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<AuthPageLayout className="auth-page">
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">احراز هویت</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<small className="font-bold mt-10 mb-4"> جهت احراز هویت</small>
|
||||
<div className="flex w-full gap-2 max-w-[300px]">
|
||||
<div>
|
||||
{/* کد ملی */}
|
||||
<AuthInput
|
||||
name="nationalCode"
|
||||
type="text"
|
||||
placeholder="کد ملی"
|
||||
className={`border mt-2 !p-1 h-[36px] ${
|
||||
formik.touched.nationalCode && formik.errors.nationalCode
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.nationalCode}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout className="auth-page">
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">
|
||||
{t("auth.identityVerification")}
|
||||
</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
{/* تاریخ تولد */}
|
||||
<div className="relative">
|
||||
<DatePicker
|
||||
id="ss"
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
value={formik.values.birthDate}
|
||||
onChange={(value) => {
|
||||
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"
|
||||
}`}
|
||||
/>
|
||||
<label htmlFor="ss" className="absolute right-4 top-3.5">
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt="calendar icon"
|
||||
src={"/images/icons/calendar.svg"}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formik.touched.nationalCode && formik.errors.nationalCode && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.nationalCode}
|
||||
</small>
|
||||
)}
|
||||
{formik.touched.birthDate && formik.errors.birthDate && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.birthDate}
|
||||
</small>
|
||||
)}
|
||||
<small className="font-bold mt-10"> جهت واریز حق الزحمه</small>
|
||||
{/* شماره شبا */}
|
||||
<div className="flex flex-col w-full relative mt-4 ">
|
||||
<span className="absolute left-4 top-1.5">IR</span>
|
||||
<AuthInput
|
||||
name="shaba"
|
||||
type="text"
|
||||
placeholder="شماره شبا"
|
||||
className={`border w-full mx-auto h-[36px] p-2 max-w-full pl-8 text-sm ${
|
||||
formik.touched.shaba && formik.errors.shaba
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.shaba}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.shaba && formik.errors.shaba && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.shaba}
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
{t("auth.forIdentityVerification")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full gap-2 max-w-[300px]">
|
||||
<div>
|
||||
<AuthInput
|
||||
name="nationalCode"
|
||||
type="text"
|
||||
placeholder={t("auth.nationalCodePlaceholder")}
|
||||
className={`border mt-2 !p-1 h-[36px] ${
|
||||
formik.touched.nationalCode && formik.errors.nationalCode
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.nationalCode}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<DatePicker
|
||||
id="ss"
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
value={formik.values.birthDate}
|
||||
onChange={(value) => {
|
||||
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"
|
||||
}`}
|
||||
/>
|
||||
<label htmlFor="ss" className="absolute right-4 top-3.5">
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt="calendar icon"
|
||||
src={"/images/icons/calendar.svg"}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<small className="font-bold mt-10">
|
||||
شماره شبا باید به نام خود شخص باشد
|
||||
</small>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
{formik.touched.nationalCode && formik.errors.nationalCode && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.nationalCode}
|
||||
</small>
|
||||
)}
|
||||
{formik.touched.birthDate && formik.errors.birthDate && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.birthDate}
|
||||
</small>
|
||||
)}
|
||||
<small className="font-bold mt-10">{t("auth.forPayment")}</small>
|
||||
<div className="flex flex-col w-full relative mt-4 ">
|
||||
<span className="absolute left-4 top-1.5">IR</span>
|
||||
<AuthInput
|
||||
name="shaba"
|
||||
type="text"
|
||||
placeholder={t("auth.shabaPlaceholder")}
|
||||
className={`border w-full mx-auto h-[36px] p-2 max-w-full pl-8 text-sm ${
|
||||
formik.touched.shaba && formik.errors.shaba
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.shaba}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.shaba && formik.errors.shaba && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.shaba}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/location")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
<small className="font-bold mt-10">{t("auth.shabaOwnerHint")}</small>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/location")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<IProfileData | null>(null);
|
||||
@@ -45,7 +46,6 @@ function AvatarPage() {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// انتخاب تصویر
|
||||
const selectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold mb-4">تصویر پروفایل</span>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold mb-4">
|
||||
{t("auth.profileImageTitle")}
|
||||
</span>
|
||||
|
||||
<div className="relative aspect-square w-[250px] bg-gray-200 rounded-3xl overflow-hidden mb-4">
|
||||
{avatar ? (
|
||||
<Cropper
|
||||
image={avatar.startsWith("data:") ? avatar : IMAGE_BASE_URL + avatar}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1} // مربع 1:1
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
cropShape="rect"
|
||||
showGrid={false}
|
||||
objectFit="contain" // نمایش کامل تصویر
|
||||
<div className="relative aspect-square w-[250px] bg-gray-200 rounded-3xl overflow-hidden mb-4">
|
||||
{avatar ? (
|
||||
<Cropper
|
||||
image={
|
||||
avatar.startsWith("data:") ? avatar : IMAGE_BASE_URL + avatar
|
||||
}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
cropShape="rect"
|
||||
showGrid={false}
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="fileInput"
|
||||
className="cursor-pointer w-full h-full flex items-center justify-center"
|
||||
>
|
||||
<Image
|
||||
src="/images/icons/gallery-add.svg"
|
||||
width={120}
|
||||
height={120}
|
||||
alt="add profile icon"
|
||||
/>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="fileInput"
|
||||
className="cursor-pointer w-full h-full flex items-center justify-center"
|
||||
>
|
||||
<Image
|
||||
src="/images/icons/gallery-add.svg"
|
||||
width={120}
|
||||
height={120}
|
||||
alt="add profile icon"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
<input
|
||||
id="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
|
||||
<AuthNextButton className="mt-4">
|
||||
<label className="cursor-pointer w-full h-full" htmlFor="fileInput">
|
||||
انتخاب/ویرایش تصویر
|
||||
</label>
|
||||
</AuthNextButton>
|
||||
</AuthPageContent>
|
||||
<AuthNextButton className="mt-4">
|
||||
<label className="cursor-pointer w-full h-full" htmlFor="fileInput">
|
||||
{t("auth.selectEditImage")}
|
||||
</label>
|
||||
</AuthNextButton>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter
|
||||
onSkip={navigateHandler}
|
||||
skipDisabled={loadingUpload}
|
||||
>
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
loading={loadingUpload}
|
||||
disabled={loadingUpload}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
<AuthFormFooter onSkip={navigateHandler} skipDisabled={loadingUpload}>
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
loading={loadingUpload}
|
||||
disabled={loadingUpload}
|
||||
>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default AvatarPage;
|
||||
export default AvatarPage;
|
||||
|
||||
@@ -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<number>(1); // Default: 1 for "قد"
|
||||
const { t } = useTranslation("common");
|
||||
const [selectedButton, setSelectedButton] = useState<number>(1);
|
||||
const [selectedHairImage, setSelectedHairImage] = useState<string>("");
|
||||
const [selectedEyeImage, setSelectedEyeImage] = useState<string>("");
|
||||
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 (
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">سایز</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">{t("auth.sizesTitle")}</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-center text-sm font-bold">مشخصات ظاهری</p>
|
||||
<p className="mt-8 text-center text-sm font-bold">
|
||||
{t("auth.appearanceDetails")}
|
||||
</p>
|
||||
|
||||
{/* Buttons for categories */}
|
||||
<div className="flex justify-center gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(1)}
|
||||
<div className="flex justify-center gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(1)}
|
||||
>
|
||||
{t("auth.eyeColor")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(2)}
|
||||
>
|
||||
{t("auth.hairColor")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 w-full overflow-y-auto flex flex-col items-center">
|
||||
{selectedButton === 1 && (
|
||||
<div className="grid grid-cols-4 gap-2 dir-ltr">
|
||||
{eyeImages.map((image) => (
|
||||
<Image
|
||||
width={75}
|
||||
height={75}
|
||||
key={image}
|
||||
src={image}
|
||||
alt={image}
|
||||
className={`cursor-pointer w-full h-auto rounded-lg ${
|
||||
selectedEyeImage === image?.split("/").pop()
|
||||
? "border-4 border-[#FC8EAC]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleImageSelect(image, "eye")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedButton === 2 && (
|
||||
<div className="grid grid-cols-4 gap-2 dir-ltr">
|
||||
{hairImages.map((image) => (
|
||||
<Image
|
||||
width={75}
|
||||
height={75}
|
||||
key={image}
|
||||
src={image}
|
||||
alt={image}
|
||||
className={`cursor-pointer w-full h-auto rounded-lg ${
|
||||
selectedHairImage === image?.split("/").pop()
|
||||
? "border-4 border-[#FC8EAC]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleImageSelect(image, "hair")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/public-relations")}>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
رنگ چشم
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(2)}
|
||||
>
|
||||
رنگ مو
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Display images based on selected button */}
|
||||
<div className="mt-6 w-full overflow-y-auto flex flex-col items-center">
|
||||
{selectedButton === 1 && (
|
||||
<div className="grid grid-cols-4 gap-2 dir-ltr">
|
||||
{eyeImages.map((image) => (
|
||||
<Image
|
||||
width={75}
|
||||
height={75}
|
||||
key={image}
|
||||
src={image}
|
||||
alt={image}
|
||||
className={`cursor-pointer w-full h-auto rounded-lg ${
|
||||
selectedEyeImage === image?.split("/").pop()
|
||||
? "border-4 border-[#FC8EAC]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleImageSelect(image, "eye")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedButton === 2 && (
|
||||
<div className="grid grid-cols-4 gap-2 dir-ltr">
|
||||
{hairImages.map((image) => (
|
||||
<Image
|
||||
width={75}
|
||||
height={75}
|
||||
key={image}
|
||||
src={image}
|
||||
alt={image}
|
||||
className={`cursor-pointer w-full h-auto rounded-lg ${
|
||||
selectedHairImage === image?.split("/").pop()
|
||||
? "border-4 border-[#FC8EAC]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleImageSelect(image, "hair")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/public-relations")}>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<boolean>(false);
|
||||
const [navigating, setNavigating] = useState(false);
|
||||
|
||||
const [verifiedStatus, setVerifiedStatus] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,114 +45,122 @@ function Confirm() {
|
||||
setShowModal(true);
|
||||
setNavigating(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center justify-center address-page ">
|
||||
<span className="text-xl font-bold text-[#238800]">
|
||||
ثبت نام شما تکمیل شد
|
||||
</span>
|
||||
<small className="font-bold mt-4 mb-4">
|
||||
ورود شما به خانواده مدستاگرام را تبریک می گوییم{" "}
|
||||
</small>
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<ProfileAvatar
|
||||
src={user?.profile_image}
|
||||
alt={user?.user_name || "user profile"}
|
||||
size="xl"
|
||||
rounded="2xl"
|
||||
/>
|
||||
<div className="flex flex-col justify-center items-center gap-1 mt-1">
|
||||
<span>{formatFullName(user?.first_name, user?.last_name)}</span>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{user?.user_name}
|
||||
<VerificationBadge
|
||||
isVerified={verifiedStatus ?? user?.is_verified ?? "pending"}
|
||||
/>
|
||||
</span>
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center justify-center address-page ">
|
||||
<span className="text-xl font-bold text-[#238800]">
|
||||
{t("auth.registrationComplete")}
|
||||
</span>
|
||||
<small className="font-bold mt-4 mb-4">
|
||||
{t("auth.welcomeMessage")}
|
||||
</small>
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<ProfileAvatar
|
||||
src={user?.profile_image}
|
||||
alt={user?.user_name || "user profile"}
|
||||
size="xl"
|
||||
rounded="2xl"
|
||||
/>
|
||||
<div className="flex flex-col justify-center items-center gap-1 mt-1">
|
||||
<span>{formatFullName(user?.first_name, user?.last_name)}</span>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{user?.user_name}
|
||||
<VerificationBadge
|
||||
isVerified={
|
||||
verifiedStatus ?? user?.is_verified ?? "pending"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small className="font-bold mt-10 mb-4 text-center">
|
||||
پس از تایید مدارک و احراز هویت، تیک آبی یا طلایی کنار نام کاربری شما
|
||||
قرار می گیرد
|
||||
</small>
|
||||
<small className="font-bold mt-10 mb-4 text-center">
|
||||
{t("auth.verificationBadgeHint")}
|
||||
</small>
|
||||
|
||||
<div className=" flex">
|
||||
<div className="">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
opacity="0.4"
|
||||
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
|
||||
fill="#0066FF"
|
||||
/>
|
||||
<path
|
||||
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
|
||||
fill="#0066FF"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
opacity="0.4"
|
||||
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
|
||||
fill="#dbd40b"
|
||||
/>
|
||||
<path
|
||||
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
|
||||
fill="#f5f507"
|
||||
/>
|
||||
</svg>
|
||||
<div className=" flex">
|
||||
<div className="">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
opacity="0.4"
|
||||
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
|
||||
fill="#0066FF"
|
||||
/>
|
||||
<path
|
||||
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
|
||||
fill="#0066FF"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
opacity="0.4"
|
||||
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
|
||||
fill="#dbd40b"
|
||||
/>
|
||||
<path
|
||||
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
|
||||
fill="#f5f507"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<AuthNextButton
|
||||
onClick={confirmHandler}
|
||||
className="mt-20"
|
||||
loading={navigating}
|
||||
disabled={navigating}
|
||||
>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
<small className="font-bold mt-10 mb-1 text-center">
|
||||
{t("auth.verificationTimeOffice")}
|
||||
</small>
|
||||
<small className="font-bold text-center">
|
||||
{t("auth.verificationTimeOffHours")}
|
||||
</small>
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
height="200px"
|
||||
>
|
||||
<p className="font-bold text-center mt-4">
|
||||
{t("auth.shareWorkHint")}
|
||||
</p>
|
||||
<div className="flex w-full justify-center items-center mt-10 gap-5">
|
||||
<Link
|
||||
href={"/new-post"}
|
||||
className="bg-sky-200 py-2 px-5 rounded-xl min-w-[120px] text-sky-600 text-center"
|
||||
>
|
||||
{t("auth.createPost")}
|
||||
</Link>
|
||||
<Link
|
||||
href={"/"}
|
||||
className="bg-sky-200 py-2 px-5 rounded-xl min-w-[120px] text-sky-600 text-center"
|
||||
>
|
||||
{t("auth.later")}
|
||||
</Link>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
{/* */}
|
||||
<AuthNextButton onClick={confirmHandler} className="mt-20" loading={navigating} disabled={navigating}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
<small className="font-bold mt-10 mb-1 text-center">
|
||||
زمان تایید مدارک 5 دقیقه تا 1 ساعت در ساعات اداری و
|
||||
</small>
|
||||
<small className="font-bold text-center">
|
||||
3 تا 8 ساعت در ساعات غیر اداری
|
||||
</small>
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
height="200px"
|
||||
>
|
||||
<p className="font-bold text-center mt-4">
|
||||
.برای بهتر دیده شدن نمونه کارهای خود را به اشتراگ بگذراید
|
||||
</p>
|
||||
<div className="flex w-full justify-center items-center mt-10 gap-5">
|
||||
<Link
|
||||
href={"/new-post"}
|
||||
className="bg-sky-200 py-2 px-5 rounded-xl min-w-[120px] text-sky-600 text-center"
|
||||
>
|
||||
ثبت پست
|
||||
</Link>
|
||||
<Link
|
||||
href={"/"}
|
||||
className="bg-sky-200 py-2 px-5 rounded-xl min-w-[120px] text-sky-600 text-center"
|
||||
>
|
||||
بعدا
|
||||
</Link>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</Container>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">نوع همکاری</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<p className="my-10 text-sm font-bold">
|
||||
آیا مایل به همکاری خارج از محل سکونت خود هستید؟
|
||||
</p>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<div className="flex w-full gap-2">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
بله خوشحال هم می شم
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
نه شهر خودم رو ترجیح میدم
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
|
||||
{formik.errors.selectedButton && formik.touched.selectedButton && (
|
||||
<div className="text-red-500 mt-2 text-sm">
|
||||
{formik.errors.selectedButton}
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">
|
||||
{t("auth.cooperationTypeTitle")}
|
||||
</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/public-relations")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
<p className="my-10 text-sm font-bold">
|
||||
{t("auth.cooperationQuestion")}
|
||||
</p>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<div className="flex w-full gap-2">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => 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")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => 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")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
|
||||
{formik.errors.selectedButton && formik.touched.selectedButton && (
|
||||
<div className="text-red-500 mt-2 text-sm">
|
||||
{formik.errors.selectedButton}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/public-relations")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>("");
|
||||
const [expertiseList, setExpertiseList] = useState<IExpertise[] | null>(null);
|
||||
const [subExpertise, setSubExpertise] = useState<string[]>([]);
|
||||
const [displaySubExpertise, setDisplaySubExpertise] = useState<string | null>(
|
||||
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 (
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">تخصص</span>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">{t("auth.expertiseTitle")}</span>
|
||||
|
||||
<AuthUserDetails />
|
||||
<AuthUserDetails />
|
||||
|
||||
<p className="mt-8 text-center">در چه زمینه ای تخصص دارید؟</p>
|
||||
<p className="mt-8 text-center">{t("auth.expertiseQuestion")}</p>
|
||||
|
||||
<ExpertisePicker
|
||||
expertiseList={expertiseList}
|
||||
expertise={expertise}
|
||||
subExpertise={subExpertise}
|
||||
onExpertiseSelect={(value) => {
|
||||
setExpertise(value);
|
||||
setSubExpertise([]);
|
||||
}}
|
||||
onSubExpertiseToggle={(value) => {
|
||||
setSubExpertise((prev) =>
|
||||
prev.includes(value)
|
||||
? prev.filter((item) => item !== value)
|
||||
: [...prev, value]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</AuthPageContent>
|
||||
<ExpertisePicker
|
||||
expertiseList={expertiseList}
|
||||
expertise={expertise}
|
||||
subExpertise={subExpertise}
|
||||
displaySubExpertise={displaySubExpertise}
|
||||
onExpertiseSelect={(value) => {
|
||||
setExpertise(value);
|
||||
setSubExpertise([]);
|
||||
setDisplaySubExpertise(null);
|
||||
}}
|
||||
onSubExpertiseToggle={handleSubExpertiseToggle}
|
||||
onDisplaySubExpertiseChange={setDisplaySubExpertise}
|
||||
/>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/services")}>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/services")}>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">جنسیت</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<div className="flex w-full gap-4">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/woman.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="gender icon"
|
||||
/>
|
||||
خانم
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/man.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="gender icon"
|
||||
/>
|
||||
آقا
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
|
||||
{formik.errors.selectedButton && formik.touched.selectedButton && (
|
||||
<div className="text-red-500 mt-2 text-sm">
|
||||
{formik.errors.selectedButton}
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">{t("auth.genderTitle")}</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/sizes")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<div className="flex w-full gap-4">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/woman.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="gender icon"
|
||||
/>
|
||||
{t("auth.female")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/man.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="gender icon"
|
||||
/>
|
||||
{t("auth.male")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
|
||||
{formik.errors.selectedButton && formik.touched.selectedButton && (
|
||||
<div className="text-red-500 mt-2 text-sm">
|
||||
{formik.errors.selectedButton}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/sizes")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HTMLSelectElement>) => {
|
||||
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 (
|
||||
<AuthPageLayout className="address-page">
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">لوکیشن</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout className="address-page">
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">{t("auth.locationTitle")}</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
مشخص کنید در کدام شهر قادر به انجام فعالیت هستید
|
||||
</small>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<SelectBox
|
||||
className={`mt-4 ${
|
||||
formik.touched.stateId && formik.errors.stateId
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.stateId}
|
||||
onChange={handleProvinceChange}
|
||||
>
|
||||
<option disabled value="">
|
||||
استان
|
||||
</option>
|
||||
{allStates?.map((item: IProvince) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item?.name}
|
||||
</option>
|
||||
))}
|
||||
</SelectBox>
|
||||
{formik.touched.stateId && formik.errors.stateId && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.stateId}
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
{t("auth.locationActivityHint")}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<SelectBox
|
||||
className={`mt-4 ${
|
||||
formik.touched.cityId && formik.errors.cityId
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.cityId}
|
||||
onChange={(e) => formik.setFieldValue("cityId", e.target.value)}
|
||||
>
|
||||
<option disabled value="">
|
||||
شهر
|
||||
</option>
|
||||
{cities?.map((city: ICity) => (
|
||||
<option key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</option>
|
||||
))}
|
||||
</SelectBox>
|
||||
{formik.touched.cityId && formik.errors.cityId && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.cityId}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
name="address"
|
||||
placeholder="آدرس"
|
||||
className={`border mt-4 w-full max-w-[300px] p-3 rounded-2xl border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
|
||||
formik.touched.address && formik.errors.address
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.address}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.address && formik.errors.address && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.address}
|
||||
</small>
|
||||
)}
|
||||
<Map
|
||||
style={{ height: "calc(100vh - 260px)" }}
|
||||
initialViewState={{
|
||||
longitude: 51.375433528216654,
|
||||
latitude: 35.73356434056531,
|
||||
zoom: 11,
|
||||
}}
|
||||
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
|
||||
mapStyle="mapbox://styles/mapbox/streets-v11"
|
||||
onClick={handleMapClick}
|
||||
>
|
||||
<GeolocateControl />
|
||||
{selectedLocation && (
|
||||
<Marker
|
||||
latitude={selectedLocation.lat}
|
||||
longitude={selectedLocation.lng}
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<SelectBox
|
||||
className={`mt-4 ${
|
||||
formik.touched.stateId && formik.errors.stateId
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.stateId}
|
||||
onChange={handleProvinceChange}
|
||||
>
|
||||
<Image
|
||||
alt="location icon"
|
||||
className="-mt-5"
|
||||
width={25}
|
||||
height={25}
|
||||
src={"/images/icons/location.svg"}
|
||||
/>
|
||||
</Marker>
|
||||
)}
|
||||
</Map>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
className="scale-125"
|
||||
type="checkbox"
|
||||
checked={isCheckedOne}
|
||||
onChange={toggleCheckBox}
|
||||
/>
|
||||
<span className="text-xs font-bold">
|
||||
اطلاعات لوکیشن شما برای همه قابل نمایش باشد
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
<option disabled value="">
|
||||
{t("auth.province")}
|
||||
</option>
|
||||
{allStates?.map((item: IProvince) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item?.name}
|
||||
</option>
|
||||
))}
|
||||
</SelectBox>
|
||||
{formik.touched.stateId && formik.errors.stateId && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.stateId}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/national-cart")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
<SelectBox
|
||||
className={`mt-4 ${
|
||||
formik.touched.cityId && formik.errors.cityId
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.cityId}
|
||||
onChange={(e) => formik.setFieldValue("cityId", e.target.value)}
|
||||
>
|
||||
<option disabled value="">
|
||||
{t("auth.city")}
|
||||
</option>
|
||||
{cities?.map((city: ICity) => (
|
||||
<option key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</option>
|
||||
))}
|
||||
</SelectBox>
|
||||
{formik.touched.cityId && formik.errors.cityId && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.cityId}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
name="address"
|
||||
placeholder={t("auth.addressPlaceholder")}
|
||||
className={`border mt-4 w-full max-w-[300px] p-3 rounded-2xl border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
|
||||
formik.touched.address && formik.errors.address
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.address}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.address && formik.errors.address && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.address}
|
||||
</small>
|
||||
)}
|
||||
<Map
|
||||
style={{ height: "calc(100vh - 260px)" }}
|
||||
initialViewState={{
|
||||
longitude: 51.375433528216654,
|
||||
latitude: 35.73356434056531,
|
||||
zoom: 11,
|
||||
}}
|
||||
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
|
||||
mapStyle="mapbox://styles/mapbox/streets-v11"
|
||||
onClick={handleMapClick}
|
||||
>
|
||||
<GeolocateControl />
|
||||
{selectedLocation && (
|
||||
<Marker
|
||||
latitude={selectedLocation.lat}
|
||||
longitude={selectedLocation.lng}
|
||||
>
|
||||
<Image
|
||||
alt="location icon"
|
||||
className="-mt-5"
|
||||
width={25}
|
||||
height={25}
|
||||
src={"/images/icons/location.svg"}
|
||||
/>
|
||||
</Marker>
|
||||
)}
|
||||
</Map>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
className="scale-125"
|
||||
type="checkbox"
|
||||
checked={isCheckedOne}
|
||||
onChange={toggleCheckBox}
|
||||
/>
|
||||
<span className="text-xs font-bold">
|
||||
{t("auth.showLocationPublic")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/national-cart")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<AuthPageLayout className="address-page">
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">احراز هویت</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout className="address-page">
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">
|
||||
{t("auth.identityVerification")}
|
||||
</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
برای ثبت درخواست، نیازمند احراز هویت شما هستیم
|
||||
</small>
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
{t("auth.nationalCardVerificationDesc")}
|
||||
</small>
|
||||
|
||||
<div className="relative w-[150px] h-[150px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-4">
|
||||
{nationalCartImg ? (
|
||||
<>
|
||||
<img
|
||||
src={
|
||||
nationalCartImg.startsWith("data:")
|
||||
? nationalCartImg
|
||||
: IMAGE_BASE_URL + nationalCartImg
|
||||
}
|
||||
alt="nationalCartImg"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={removeImage}
|
||||
className="p-4 absolute top-0 right-0"
|
||||
<div className="relative w-[150px] h-[150px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-4">
|
||||
{nationalCartImg ? (
|
||||
<>
|
||||
<img
|
||||
src={
|
||||
nationalCartImg.startsWith("data:")
|
||||
? nationalCartImg
|
||||
: IMAGE_BASE_URL + nationalCartImg
|
||||
}
|
||||
alt="nationalCartImg"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={removeImage}
|
||||
className="p-4 absolute top-0 right-0"
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/close-circle.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="remove profile icon"
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="fileInput"
|
||||
className="cursor-pointer w-[150px] h-[150px] flex items-center justify-center text-white rounded-3xl border border-[#676767]"
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/close-circle.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="remove profile icon"
|
||||
src={"/images/icons/gallery-add.svg"}
|
||||
width={76}
|
||||
height={76}
|
||||
alt="add profile icon"
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="fileInput"
|
||||
className="cursor-pointer w-[150px] h-[150px] flex items-center justify-center text-white rounded-3xl border border-[#676767]"
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/gallery-add.svg"}
|
||||
width={76}
|
||||
height={76}
|
||||
alt="add profile icon"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment" // 🔥 مستقیم دوربین پشت
|
||||
className="hidden"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
اصل کارت ملی را بر روی سینه در دست گرفته و از خود عکس بگیرید
|
||||
</small>
|
||||
|
||||
<div className="flex items-center gap-2 mt-10">
|
||||
<input
|
||||
className="scale-125"
|
||||
type="checkbox"
|
||||
onChange={toggleCheckBox}
|
||||
id="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
<span
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="text-xs font-bold cursor-pointer"
|
||||
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
{t("auth.nationalCardPhotoHint")}
|
||||
</small>
|
||||
|
||||
<div className="flex items-center gap-2 mt-10">
|
||||
<input
|
||||
className="scale-125"
|
||||
type="checkbox"
|
||||
onChange={toggleCheckBox}
|
||||
/>
|
||||
<span
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="text-xs font-bold cursor-pointer"
|
||||
>
|
||||
{t("auth.acceptRulesLabel")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/confirm")}>
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
تایید قوانین و مقررات
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/confirm")}>
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">روابط عمومی</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">
|
||||
{t("auth.publicRelationsTitle")}
|
||||
</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<small className="font-bold mt-10"> در مورد خودتان چیزی بگویید</small>
|
||||
<textarea
|
||||
name="bio"
|
||||
placeholder="بیو"
|
||||
className={`border mt-4 w-full max-w-[290px] p-3 rounded-2xl border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
|
||||
formik.touched.bio && formik.errors.bio
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.bio}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length <= 500) {
|
||||
formik.handleChange(e);
|
||||
}
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<small className="font-bold mt-10">
|
||||
{t("auth.bioAboutYou")}
|
||||
</small>
|
||||
<textarea
|
||||
name="bio"
|
||||
placeholder={t("auth.bioPlaceholder")}
|
||||
className={`border mt-4 w-full max-w-[290px] p-3 rounded-2xl border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
|
||||
formik.touched.bio && formik.errors.bio
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.bio}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length <= 500) {
|
||||
formik.handleChange(e);
|
||||
}
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
|
||||
<small className="mt-1 text-gray-500 block text-right">
|
||||
{formik.values.bio.length}/500
|
||||
</small>
|
||||
<small className="mt-1 text-gray-500 block text-right">
|
||||
{formik.values.bio.length}/500
|
||||
</small>
|
||||
|
||||
{formik.touched.bio && formik.errors.bio && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.bio}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
{formik.touched.bio && formik.errors.bio && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.bio}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/location")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/location")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Service[]>([]);
|
||||
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 (
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent className="w-full">
|
||||
<span className="text-xl font-bold">خدمات</span>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent className="w-full">
|
||||
<span className="text-xl font-bold">{t("auth.servicesTitle")}</span>
|
||||
|
||||
<div className="mt-5 w-full max-w-md">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<div className="mt-5 w-full max-w-md">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<div className="gap-4 min-h-[384px] max-h-96 overflow-y-auto w-full mt-4">
|
||||
{services.map((service) => (
|
||||
<ServiceItem
|
||||
onDelete={handleDeleteService}
|
||||
key={service.id}
|
||||
service={service}
|
||||
<div className="gap-4 min-h-[384px] max-h-96 overflow-y-auto w-full mt-4">
|
||||
{services.map((service) => (
|
||||
<ServiceItem
|
||||
onDelete={handleDeleteService}
|
||||
key={service.id}
|
||||
service={service}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<AddServiceModal
|
||||
onClose={() => setModalOpen(false)}
|
||||
onAdd={handleAddService}
|
||||
isModalOpen={isModalOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AuthPageContent>
|
||||
|
||||
{isModalOpen && (
|
||||
<AddServiceModal
|
||||
onClose={() => setModalOpen(false)}
|
||||
onAdd={handleAddService}
|
||||
isModalOpen={isModalOpen}
|
||||
/>
|
||||
)}
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter onSkip={Reject}>
|
||||
<div className="flex gap-4">
|
||||
<AuthNextButton
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
className="w-32"
|
||||
>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
|
||||
<AuthFormFooter onSkip={Reject}>
|
||||
<div className="flex gap-4">
|
||||
<AuthNextButton
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
className="w-32"
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
|
||||
<AuthNextButton
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="!text-[#0066FF] !border-[#0066FF] w-32"
|
||||
>
|
||||
افزودن
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
<AuthNextButton
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="!text-[#0066FF] !border-[#0066FF] w-32"
|
||||
>
|
||||
{t("auth.addService")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string>("");
|
||||
const [weight, setWeight] = useState<string>("");
|
||||
const [size, setSize] = useState<string | null>(null);
|
||||
const [selectedButton, setSelectedButton] = useState<number>(1); // Default: 1 for "قد"
|
||||
const [selectedButton, setSelectedButton] = useState<number>(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 (
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">سایز</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">{t("auth.sizesTitle")}</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-center text-sm font-bold">مشخصات ظاهری</p>
|
||||
<p className="mt-8 text-center text-sm font-bold">
|
||||
{t("auth.appearanceDetails")}
|
||||
</p>
|
||||
|
||||
{/* Buttons for categories */}
|
||||
<div className="flex gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(1)}
|
||||
<div className="flex gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(1)}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/ruler&pen.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
className="dark:invert"
|
||||
alt={t("auth.height")}
|
||||
/>
|
||||
{t("auth.height")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(2)}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/weight.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
className="dark:invert"
|
||||
alt={t("auth.weight")}
|
||||
/>
|
||||
{t("auth.weight")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 3
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(3)}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/timer.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
className="dark:invert"
|
||||
alt={t("auth.size")}
|
||||
/>
|
||||
{t("auth.size")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 w-full max-w-md flex flex-col items-center">
|
||||
{selectedButton === 1 && (
|
||||
<input
|
||||
type="number"
|
||||
placeholder={t("auth.height")}
|
||||
className="w-20 mx-auto p-2 mb-4 border rounded-full text-center bg-tertiary-light dark:bg-tertiary-dark"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{selectedButton === 2 && (
|
||||
<input
|
||||
type="number"
|
||||
placeholder={t("auth.weight")}
|
||||
className="w-20 mx-auto p-2 mb-4 border rounded-full text-center bg-tertiary-light dark:bg-tertiary-dark"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{selectedButton === 3 && (
|
||||
<div className="grid grid-cols-3 gap-2 w-full max-w-[320px]">
|
||||
{sizes.map((sizeOption) => (
|
||||
<button
|
||||
key={sizeOption}
|
||||
className={`p-2 py-1 rounded-full border text-sm full ${
|
||||
size === sizeOption
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleSizeSelection(sizeOption)}
|
||||
>
|
||||
{sizeOption}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/colors")}>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/ruler&pen.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
className="dark:invert"
|
||||
alt="قد"
|
||||
/>
|
||||
قد
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(2)}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/weight.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
className="dark:invert"
|
||||
alt="وزن"
|
||||
/>
|
||||
وزن
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 3
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(3)}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/timer.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
className="dark:invert"
|
||||
alt="سایز"
|
||||
/>
|
||||
سایز
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inputs based on selected button */}
|
||||
<div className="mt-6 w-full max-w-md flex flex-col items-center">
|
||||
{selectedButton === 1 && (
|
||||
<input
|
||||
type="number"
|
||||
placeholder="قد"
|
||||
className="w-20 mx-auto p-2 mb-4 border rounded-full text-center bg-tertiary-light dark:bg-tertiary-dark"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{selectedButton === 2 && (
|
||||
<input
|
||||
type="number"
|
||||
placeholder="وزن"
|
||||
className="w-20 mx-auto p-2 mb-4 border rounded-full text-center bg-tertiary-light dark:bg-tertiary-dark"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{selectedButton === 3 && (
|
||||
<div className="grid grid-cols-3 gap-2 w-full max-w-[320px]">
|
||||
{sizes.map((sizeOption) => (
|
||||
<button
|
||||
key={sizeOption}
|
||||
className={`p-2 py-1 rounded-full border text-sm full ${
|
||||
size === sizeOption
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleSizeSelection(sizeOption)}
|
||||
>
|
||||
{sizeOption}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/colors")}>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
{t("auth.saveAndContinue")}
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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({
|
||||
|
||||
@@ -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<HTMLVideoElement>
|
||||
) => {
|
||||
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
|
||||
>
|
||||
<source src={"https://app.modstagram.ir" + selectedVideo?.course_video} type="video/mp4" />
|
||||
مرورگر شما از پخش ویدیو پشتیبانی نمیکند
|
||||
{t("academy.course.videoUnsupported")}
|
||||
</video>
|
||||
|
||||
{!selectedVideo?.is_free && !isPurchased && (
|
||||
<div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center backdrop-blur-sm">
|
||||
<Lock className="w-20 h-20 text-white mb-4" />
|
||||
<p className="text-white text-xl font-bold mb-2">
|
||||
این ویدیو رایگان نیست
|
||||
{t("academy.course.videoNotFree")}
|
||||
</p>
|
||||
<p className="text-gray-300 text-sm mb-6">
|
||||
برای مشاهده تمام ویدیوها دوره را تهیه کنید
|
||||
{t("academy.course.purchaseToWatch")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -673,8 +680,10 @@ useEffect(() => {
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
{courses[0]?.is_free
|
||||
? "دریافت رایگان پکیج"
|
||||
: `خرید دوره با ${Math.round(finalPrice).toLocaleString()} تومان`}
|
||||
? t("academy.course.getFreePackage")
|
||||
: t("academy.course.buyForPrice", {
|
||||
price: Math.round(finalPrice).toLocaleString(),
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -685,7 +694,9 @@ useEffect(() => {
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-gray-400">
|
||||
<span> {selectedVideo?.duration || ""}</span>
|
||||
{selectedVideo?.is_free && (
|
||||
<span className="text-green-500">✓ ویدیوی رایگان</span>
|
||||
<span className="text-green-500">
|
||||
✓ {t("academy.course.freeVideo")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -695,7 +706,7 @@ useEffect(() => {
|
||||
<div className="bg-neutral-200 dark:bg-neutral-800 text-black dark:text-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
<BookOpen className="w-5 h-5 text-[#FF107D]" />
|
||||
درباره دوره
|
||||
{t("academy.course.about")}
|
||||
</h2>
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
{courses[0]?.caption || course.caption}
|
||||
@@ -706,9 +717,11 @@ useEffect(() => {
|
||||
<div className="bg-neutral-200 dark:bg-neutral-800 text-black dark:text-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-yellow-500" />
|
||||
نظرات دانشجویان
|
||||
{t("academy.course.studentComments")}
|
||||
{totalComments > 0 && (
|
||||
<span className="text-sm text-gray-500">({totalComments} نظر)</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{t("academy.course.commentCount", { count: totalComments })}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
@@ -716,7 +729,9 @@ useEffect(() => {
|
||||
<div className="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">میانگین امتیاز</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t("academy.course.averageRating")}
|
||||
</p>
|
||||
<RatingStars rate={rating || course.averageRate} />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">
|
||||
@@ -731,7 +746,7 @@ useEffect(() => {
|
||||
>
|
||||
{!loading && comments.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-10">
|
||||
هنوز نظری ثبت نشده است. اولین نفری باشید که نظر میدهید!
|
||||
{t("academy.course.noReviewsYet")}
|
||||
</p>
|
||||
) : (
|
||||
comments.map((item, index) => (
|
||||
@@ -747,7 +762,7 @@ useEffect(() => {
|
||||
<div className="shrink-0">
|
||||
<ProfileAvatar
|
||||
src={item?.user_id?.profile_image}
|
||||
alt={item?.user_id?.user_name || "کاربر"}
|
||||
alt={item?.user_id?.user_name || t("common.user")}
|
||||
size="chat"
|
||||
rounded="full"
|
||||
fallback="/images/default-avatar.png"
|
||||
@@ -758,7 +773,7 @@ useEffect(() => {
|
||||
{/* اطلاعات کاربر و امتیاز */}
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
<span className="font-semibold text-sm dark:text-white inline-flex items-center gap-1">
|
||||
{item?.user_id?.user_name || "کاربر ناشناس"}
|
||||
{item?.user_id?.user_name || t("common.user")}
|
||||
<VerificationBadge isVerified={item?.user_id?.is_verified} />
|
||||
</span>
|
||||
|
||||
@@ -782,7 +797,7 @@ useEffect(() => {
|
||||
<span>
|
||||
{item.createdAt
|
||||
? new Date(item.createdAt).toLocaleDateString("fa-IR")
|
||||
: "تاریخ نامشخص"}
|
||||
: t("academy.course.unknownDate")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
❤️ {item.likes || 0}
|
||||
@@ -798,7 +813,7 @@ useEffect(() => {
|
||||
<div className="text-center py-4">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white"></div>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
در حال بارگذاری نظرات...
|
||||
{t("academy.course.loadingComments")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -835,27 +850,34 @@ useEffect(() => {
|
||||
{/* قیمت */}
|
||||
<div className="mb-4">
|
||||
{courses[0]?.is_free ? (
|
||||
<span className="text-3xl font-bold text-green-600">رایگان</span>
|
||||
<span className="text-3xl font-bold text-green-600">
|
||||
{t("academy.course.free")}
|
||||
</span>
|
||||
) : courses[0]?.offerNumber > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-3xl font-bold text-[#FF107D]">
|
||||
{Math.round(
|
||||
courses[0]?.price * (1 - courses[0]?.offerNumber / 100)
|
||||
).toLocaleString()} تومان
|
||||
).toLocaleString()}{" "}
|
||||
{t("settings.toman")}
|
||||
</span>
|
||||
<span className="text-sm line-through text-gray-400">
|
||||
{Math.round(courses[0]?.price).toLocaleString()} تومان
|
||||
{Math.round(courses[0]?.price).toLocaleString()}{" "}
|
||||
{t("settings.toman")}
|
||||
</span>
|
||||
<span className="bg-red-500 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
||||
<Percent className="w-3 h-3" />
|
||||
{courses[0]?.offerNumber}% تخفیف
|
||||
{t("academy.course.discount", {
|
||||
percent: courses[0]?.offerNumber,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-3xl font-bold text-[#FF107D]">
|
||||
{Math.round(courses[0]?.price || course.price).toLocaleString()} تومان
|
||||
{Math.round(courses[0]?.price || course.price).toLocaleString()}{" "}
|
||||
{t("settings.toman")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -864,7 +886,7 @@ useEffect(() => {
|
||||
{isPurchased ? (
|
||||
<div className="bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 p-3 rounded-lg flex items-center justify-center gap-2">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<span className="font-medium">دوره خریداری شده است</span>
|
||||
<span className="font-medium">{t("academy.course.purchased")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
@@ -875,11 +897,11 @@ useEffect(() => {
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
{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")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -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]"
|
||||
>
|
||||
<Phone className="w-5 h-5" />
|
||||
اطلاعات تماس
|
||||
{t("academy.course.contactInfo")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -898,24 +920,31 @@ useEffect(() => {
|
||||
<div className="mt-6 space-y-3 border-t pt-4">
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
|
||||
<User className="w-4 h-4 text-[#FF107D]" />
|
||||
<span className="text-sm">مدرس: {courses[0]?.teacher_name || course.teacher_name}</span>
|
||||
<span className="text-sm">
|
||||
{t("academy.course.teacher")}{" "}
|
||||
{courses[0]?.teacher_name || course.teacher_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
|
||||
<Clock className="w-4 h-4 text-[#FF107D]" />
|
||||
<span className="text-sm">
|
||||
مدت زمان: {courses[0]?.course_time || course.course_time}
|
||||
{t("academy.course.duration")}{" "}
|
||||
{courses[0]?.course_time || course.course_time}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
|
||||
<FileVideo className="w-4 h-4 text-[#FF107D]" />
|
||||
<span className="text-sm">
|
||||
تعداد ویدیوها: {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}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
|
||||
<Tag className="w-4 h-4 text-[#FF107D]" />
|
||||
<span className="text-sm">
|
||||
دسته بندی: {courses[0]?.category || course.category}
|
||||
{t("academy.course.category")}{" "}
|
||||
{courses[0]?.category || course.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -928,9 +957,11 @@ useEffect(() => {
|
||||
<div className="bg-neutral-200 dark:bg-neutral-800 text-black dark:text-white rounded-xl p-6 shadow-sm">
|
||||
<h3 className="font-bold text-lg mb-4 flex items-center gap-2">
|
||||
<Play className="w-5 h-5 text-[#FF107D]" />
|
||||
سرفصلهای دوره
|
||||
{t("academy.course.syllabus")}
|
||||
<span className="text-sm text-gray-400 mr-2">
|
||||
({courses[0]?.number_of_course_content} ویدیو)
|
||||
{t("academy.course.videoCountShort", {
|
||||
count: courses[0]?.number_of_course_content,
|
||||
})}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
@@ -989,7 +1020,7 @@ useEffect(() => {
|
||||
<div className="flex items-center gap-2">
|
||||
{video.is_free && !isPurchased && (
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">
|
||||
رایگان
|
||||
{t("academy.course.free")}
|
||||
</span>
|
||||
)}
|
||||
{isLocked && <Lock className="w-4 h-4 text-gray-400" />}
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<LocalePageShell>{children}</LocalePageShell>
|
||||
<TabNavigation currentPage="/academy" />
|
||||
</section>
|
||||
);
|
||||
|
||||
15
src/app/(projects)/academy/loading.tsx
Normal file
15
src/app/(projects)/academy/loading.tsx
Normal file
@@ -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 (
|
||||
<div className="mx-auto flex min-h-[50vh] w-full max-w-xl flex-col items-center justify-center gap-3 px-4">
|
||||
<IOSSpinner size={28} color="#ff107d" />
|
||||
<span className="text-sm text-neutral-500">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Project>();
|
||||
const [typeList, setTypeList] = useState<IProjectType[] | null>(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 (
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#FF0000] ">
|
||||
پرداخت ناموفق
|
||||
{t("academy.payment.failed")}
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt={"verify icon"}
|
||||
alt={t("academy.payment.verifyIconAlt")}
|
||||
src={`/images/icons/failed.svg`}
|
||||
className="pb-1 mb-5"
|
||||
/>
|
||||
<span> پرداخت شما با خطا مواجه شد. </span>
|
||||
<span> برای تایید درخواست پرداخت خود را کامل کنید</span>
|
||||
<span>{t("academy.payment.failedHint")}</span>
|
||||
<span>{t("academy.payment.completePayment")}</span>
|
||||
</div>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{project && <MainProjectCard project={project} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{project?.project_type === "normal"
|
||||
? "نمایش ساده"
|
||||
: project?.project_type === "force"
|
||||
? "نمایش با برچسب فوری"
|
||||
: "نمایش با رنگ پس زمینه متفاوت"}
|
||||
: {Number(price).toLocaleString()} تومان
|
||||
{getDisplayTypeLabel(project?.project_type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
مبلغ قابل پرداخت: {Number(price).toLocaleString()} تومان
|
||||
{t("academy.payment.payableAmount", {
|
||||
price: Number(price).toLocaleString(),
|
||||
})}
|
||||
</RoundedDiv>
|
||||
<RoundedButton
|
||||
onClick={() => {
|
||||
payHandler();
|
||||
}}
|
||||
variant="primary" className="w-32 h-9"
|
||||
variant="primary"
|
||||
className="w-32 h-9"
|
||||
>
|
||||
پرداخت مجدد
|
||||
{t("academy.payment.retry")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<Project>();
|
||||
const [typeList, setTypeList] = useState<IProjectType[] | null>(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 (
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#17A600] ">
|
||||
پرداخت موفق
|
||||
{t("academy.payment.success")}
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt={"verify icon"}
|
||||
alt={t("academy.payment.verifyIconAlt")}
|
||||
src={`/images/icons/success.svg`}
|
||||
className="pb-1 mb-5"
|
||||
/>
|
||||
@@ -69,19 +83,13 @@ function SuccessProject() {
|
||||
{project && <MainProjectCard project={project} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{project?.project_type === "normal"
|
||||
? "نمایش ساده"
|
||||
: project?.project_type === "force"
|
||||
? "نمایش با برچسب فوری"
|
||||
: "نمایش با رنگ پس زمینه متفاوت"}
|
||||
: {Number(price).toLocaleString()} تومان
|
||||
{getDisplayTypeLabel(project?.project_type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
<p className="my-5">
|
||||
پروژه شما پس از بررسی توسط کارشناسان ما منتشر خواهد شد
|
||||
</p>
|
||||
<p className="my-5">{t("academy.payment.reviewNotice")}</p>
|
||||
<Link href={"/settings/workroom"}>
|
||||
<RoundedButton variant="primary" className="w-32 h-9">
|
||||
اتاق کار
|
||||
{t("academy.payment.workroom")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<Container>
|
||||
<div className="py-20 text-center text-neutral-500">آموزشگاه یافت نشد.</div>
|
||||
<div className="py-20 text-center text-neutral-500">
|
||||
{t("academy.profile.notFound")}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Container className="pb-28">
|
||||
<div className="w-full">
|
||||
<div
|
||||
className="flex items-center justify-end cursor-pointer gap-1 px-4 py-2 text-xs md:text-sm font-semibold"
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-end gap-1.5 px-4 py-2 text-xs font-semibold md:text-sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
toast.success("لینک آموزشگاه کپی شد");
|
||||
toast.success(t("academy.profile.addressCopied"));
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt="copy icon"
|
||||
alt=""
|
||||
src="/images/icons/copy.svg"
|
||||
className="dark:invert"
|
||||
/>
|
||||
<span>{shareUrl}</span>
|
||||
</div>
|
||||
<span>{t("academy.profile.copyAddress")}</span>
|
||||
</button>
|
||||
|
||||
<div className="flex w-full items-center justify-between px-4 py-2">
|
||||
<div className="flex flex-col items-center text-xs md:text-sm">
|
||||
<div className="flex gap-5 mt-2">
|
||||
<div className="font-semibold flex flex-col items-center max-sm:text-[10px]">
|
||||
<span>{stats?.totalVideos ?? 0}</span>
|
||||
<span>کل ویدئوها</span>
|
||||
<span>{t("academy.profile.totalVideos")}</span>
|
||||
</div>
|
||||
<div className="font-semibold flex flex-col items-center text-[#0C8002] max-sm:text-[10px]">
|
||||
<span>{stats?.packagesCount ?? 0}</span>
|
||||
<span>تعداد پکیجها</span>
|
||||
<span>{t("academy.profile.packageCount")}</span>
|
||||
</div>
|
||||
<div className="font-semibold flex flex-col items-center text-[#3A59A9] max-sm:text-[10px]">
|
||||
<span>{stats?.soldCount ?? 0}</span>
|
||||
<span>تعداد فروخته شده</span>
|
||||
<span>{t("academy.profile.soldCount")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,22 +257,22 @@ export default function AcademyProfileClient() {
|
||||
|
||||
<div className="flex justify-between items-end px-4 py-2 w-full text-xs md:text-sm font-semibold">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<span>{Number(academy.rate || 0).toFixed(1)}</span>
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt="امتیاز"
|
||||
src="/images/icons/star1.svg"
|
||||
alt={t("academy.profile.ratingAlt")}
|
||||
src="/images/icons/star1.png"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<span>{displayUserScore || 0}</span>
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt="امتیاز کاربر"
|
||||
src="/images/icons/medal-star.svg"
|
||||
alt={t("academy.profile.userRatingAlt")}
|
||||
src="/images/icons/medal-star.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,50 +295,73 @@ export default function AcademyProfileClient() {
|
||||
|
||||
<div className="px-4 py-2 w-full text-xs md:text-sm font-semibold">
|
||||
<p className="leading-relaxed text-neutral-700 dark:text-neutral-300">
|
||||
{academy.bio || "توضیحاتی برای این آموزشگاه ثبت نشده است."}
|
||||
{academy.bio || t("academy.profile.noBio")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 w-full text-xs md:text-sm font-semibold">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-1 md:gap-4">
|
||||
<div className="mt-2 grid grid-cols-2 gap-1 md:grid-cols-4 md:gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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")}
|
||||
</button>
|
||||
<RoundedButton
|
||||
onClick={() => openContact("instagram", academy.contactInfo)}
|
||||
className="text-[9px] md:text-sm h-7 md:h-8 max-sm:text-[8px]"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openContact("instagram", academy.contactInfo, t)}
|
||||
disabled={!isContactAvailable("instagram", academy.contactInfo)}
|
||||
className={cn(
|
||||
profileActionBtnClass,
|
||||
!isContactAvailable("instagram", academy.contactInfo) &&
|
||||
"opacity-50"
|
||||
)}
|
||||
>
|
||||
اینستاگرام
|
||||
</RoundedButton>
|
||||
{t("academy.profile.contact.instagram")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 mt-6">
|
||||
<h2 className="font-bold text-base mb-4">پکیجهای آموزشی</h2>
|
||||
<h2 className="font-bold text-base mb-4">
|
||||
{t("academy.profile.trainingPackages")}
|
||||
</h2>
|
||||
{isLoadingCourses ? (
|
||||
<AcademyPackageListSkeleton count={3} />
|
||||
) : courses.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-10">
|
||||
هنوز پکیجی برای این آموزشگاه ثبت نشده است.
|
||||
{t("academy.profile.noPackages")}
|
||||
</p>
|
||||
) : (
|
||||
courses.map((course) => (
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<TabNavigation currentPage="/projects" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
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,
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Project | null>(null);
|
||||
const [projectRequests, setProjectRequests] = useState<IProjectRequest[]>([]);
|
||||
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 (
|
||||
<Container>
|
||||
<p className="py-20 text-center text-neutral-500">پروژه یافت نشد.</p>
|
||||
<p className="py-20 text-center text-neutral-500">
|
||||
{t("projects.notFound")}
|
||||
</p>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="pb-28">
|
||||
<MainProjectCard project={project} full />
|
||||
<ProjectRequestForm
|
||||
projectId={id}
|
||||
creatorId={
|
||||
(project as Project & { creator_id?: string }).creator_id ||
|
||||
project.creator?._id
|
||||
}
|
||||
/>
|
||||
<MainProjectCard project={project} full />
|
||||
{projectRequests.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2 text-center text-xs font-semibold md:text-sm">
|
||||
{t("projects.requestUsersTitle")}
|
||||
</p>
|
||||
<ProjectRequests projectRequests={projectRequests} />
|
||||
</div>
|
||||
) : null}
|
||||
<ProjectRequestForm
|
||||
projectId={id}
|
||||
creatorId={
|
||||
(project as Project & { creator_id?: string }).creator_id ||
|
||||
project.creator?._id
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<LocalePageShell>{children}</LocalePageShell>
|
||||
<TabNavigation currentPage="/projects" />
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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) {
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<>
|
||||
<Header />
|
||||
<Container shell>
|
||||
{/* H1 مخفی برای بهبود سئو بر اساس آدرس صفحه */}
|
||||
<h1 className="sr-only">
|
||||
{(!urlParams.slug || urlParams.slug.length === 0) &&
|
||||
!filters.expertise &&
|
||||
!filters.province &&
|
||||
!filters.city &&
|
||||
!filters.userLevel
|
||||
? pageSeo.home.title.replace(" | مدستاگرام", "")
|
||||
: `استخدام ${displayExpertise}${levelText} در مدستاگرام`}
|
||||
</h1>
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<h1 className="sr-only">{seo.h1}</h1>
|
||||
|
||||
{/* کامپوننت فیلتر با مقدار تخصص فعلی */}
|
||||
<ModelsFilter expertise={expertise || filters.expertise || ""} />
|
||||
|
||||
<StoriesBar />
|
||||
<StoriesBar token={token} />
|
||||
|
||||
{/* نمایش پستها با تمام فیلترهای استخراج شده */}
|
||||
<InfinitePosts
|
||||
filters={{
|
||||
...filters,
|
||||
@@ -209,7 +113,8 @@ export default async function Models({ params, searchParams }: IModelsProps) {
|
||||
token={token}
|
||||
/>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Metadata> {
|
||||
return generateSeoPageMetadata("about");
|
||||
}
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -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 (
|
||||
<Container>
|
||||
<div className="min-h-[500px] flex flex-col justify-around max-w-md mx-auto px-4">
|
||||
<div className="">
|
||||
|
||||
<h6 className="text-center mt-10 font-bold text-xl">
|
||||
راه های ارتباطی با مجموعه مدستاگرام
|
||||
</h6>
|
||||
<div className="flex flex-col w-full mt-20 gap-1 ">
|
||||
<span>ایمیل مجموعه : </span>
|
||||
<span> modstagram.com@gmail.com</span>
|
||||
</div>
|
||||
<div className="flex flex-col w-full mt-6 gap-1">
|
||||
<span>شماره تماس : </span>
|
||||
<span>09128893712</span>
|
||||
</div>
|
||||
<h6 className="text-center mt-10 font-bold text-xl">{t("aboutPage.title")}</h6>
|
||||
<div className="flex flex-col w-full mt-20 gap-1 ">
|
||||
<span>{t("aboutPage.emailLabel")}</span>
|
||||
<span> modstagram.com@gmail.com</span>
|
||||
</div>
|
||||
<div className="flex flex-col w-full mt-6 gap-1">
|
||||
<span>{t("aboutPage.phoneLabel")}</span>
|
||||
<span>09128893712</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex justify-center items-center">
|
||||
|
||||
<Link
|
||||
href={"/settings/tickets/new"}
|
||||
className="flex flex-col mt-6 gap-1"
|
||||
>
|
||||
<RoundedDiv className="w-28 py-1">
|
||||
<span>ارسال تیکت</span>
|
||||
</RoundedDiv>
|
||||
</Link>
|
||||
<Link href={"/settings/tickets/new"} className="flex flex-col mt-6 gap-1">
|
||||
<RoundedDiv className="w-28 py-1">
|
||||
<span>{t("aboutPage.sendTicket")}</span>
|
||||
</RoundedDiv>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
10
src/app/api/auth/config/route.ts
Normal file
10
src/app/api/auth/config/route.ts
Normal file
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<Container>
|
||||
<h1 className="sr-only">
|
||||
{(!urlParams.slug || urlParams.slug.length === 0) &&
|
||||
!filters.province &&
|
||||
!filters.city &&
|
||||
!filters.category &&
|
||||
!filters.search
|
||||
? pageSeo.billboards.title.replace(" | مدستاگرام", "")
|
||||
: `تبلیغات ${categoryLabel}${locationLabel} در مدستاگرام`}
|
||||
</h1>
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<h1 className="sr-only">{seo.h1}</h1>
|
||||
|
||||
<BillboardsFilter />
|
||||
|
||||
{/* ۴. پاس دادن فیلترهای ترکیبی به کامپوننت اینفینیت */}
|
||||
<InfiniteBillboard
|
||||
initialData={initialData}
|
||||
filters={combinedFilters}
|
||||
token={token}
|
||||
/>
|
||||
</Container>
|
||||
<BillboardsFilter />
|
||||
|
||||
<Suspense fallback={<AcademyListSkeleton count={3} />}>
|
||||
<BillboardsListSection filters={combinedFilters} token={token} />
|
||||
</Suspense>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 <Container className="py-20 text-center font-bold">بیلبورد یافت نشد!</Container>;
|
||||
return <BillboardNotFound />;
|
||||
}
|
||||
|
||||
const billboard = data.advertising as IAdvertising;
|
||||
const rate = data.rate as IRate;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<a
|
||||
href={`/billboards/profile/${billboard?.creatorId}/${encodeURIComponent(billboard?.title || "")}`}
|
||||
>
|
||||
<h1 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-text-blue-light dark:text-text-blue-dark">
|
||||
{billboard?.title}
|
||||
</h1>
|
||||
</a>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex gap-4">
|
||||
<a
|
||||
href={`/billboards/profile/${billboard?.creatorId}/${encodeURIComponent(billboard?.title || "")}`}
|
||||
>
|
||||
<h2 className="text-text-green-light dark:text-text-green-dark font-semibold line-clamp-2 ">
|
||||
{billboard?.category}
|
||||
</h2>
|
||||
</a>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<>
|
||||
<span>{rate?.adTotalRatings ? rate?.adTotalRatings : "0"}</span>
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt={"medal-star icon"}
|
||||
src={`/images/icons/medal-star.svg`}
|
||||
className="pb-1"
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<>
|
||||
<span>
|
||||
{rate?.adAverageRating ? rate?.adAverageRating : "0"}
|
||||
</span>
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt={"star icon"}
|
||||
src={`/images/icons/star1.svg`}
|
||||
className="pb-1"
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
{billboard?.showDiscount && billboard?.mostDiscountPercentage && (
|
||||
<div className="text-md bg-red-600 text-white w-16 h-8 flex items-center justify-center rounded-full pt-1">
|
||||
{billboard?.mostDiscountPercentage}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{billboard?.images && (
|
||||
<BillboardImageSlider images={billboard?.images} />
|
||||
)}
|
||||
<hr />
|
||||
<MainBillboardCardActions
|
||||
_id={billboard?._id}
|
||||
likedByUser={billboard?.likedByUser}
|
||||
likesCount={billboard?.likesCount}
|
||||
commentsCount={billboard?.commentsCount}
|
||||
viewCount={billboard?.viewCount}
|
||||
isDetail={true}
|
||||
/>
|
||||
|
||||
{/* بخش نمایش مکان بهبود یافته برای سئو و خوانایی */}
|
||||
<div className="flex flex-wrap gap-4 mt-6 p-4 bg-gray-50 dark:bg-zinc-900 rounded-xl">
|
||||
<div className="flex gap-1 text-gray-500">
|
||||
<span>استان:</span>
|
||||
<span className="text-black dark:text-white">{billboard?.province?.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-1 text-gray-500">
|
||||
<h3>شهر:</h3>
|
||||
<span className="text-black dark:text-white">{billboard?.city?.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-1 text-gray-500">
|
||||
<h4>محله:</h4>
|
||||
<span className="text-black dark:text-white">{billboard?.neighbourhood}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4 mb-8">
|
||||
<span className="text-gray-500">آدرس:</span>
|
||||
<h5 className="font-bold">{billboard?.address || "ثبت نشده"}</h5>
|
||||
</div>
|
||||
|
||||
<BillboardDetails
|
||||
services={billboard?.services}
|
||||
features={billboard?.features}
|
||||
contactInfo={billboard?.contactInfo}
|
||||
description={billboard?.description}
|
||||
lat={billboard?.lat}
|
||||
lng={billboard?.lng}
|
||||
_id={billboard?._id}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
return <BillboardDetailContent billboard={billboard} rate={rate} />;
|
||||
}
|
||||
|
||||
export default BillboardPage;
|
||||
export default BillboardPage;
|
||||
|
||||
5
src/app/billboards/loading.tsx
Normal file
5
src/app/billboards/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import BillboardsLoadingContent from "@/components/billboards/BillboardsLoadingContent";
|
||||
|
||||
export default function BillboardsLoading() {
|
||||
return <BillboardsLoadingContent />;
|
||||
}
|
||||
@@ -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<IAdvertising>();
|
||||
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(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 (
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-text-blue-light dark:text-text-blue-dark">
|
||||
پرداخت
|
||||
</h6>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{advertising && <MainBillboardCard billboard={advertising} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{advertising?.type === "special"
|
||||
? "ویژه"
|
||||
: advertising?.type === "normal"
|
||||
? "نمایش ساده"
|
||||
: advertising?.type === "free"
|
||||
? "رایگان"
|
||||
: "برجسته"}
|
||||
: {Number(price).toLocaleString()} تومان
|
||||
</RoundedDiv>
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
مبلغ قابل پرداخت: {Number(price).toLocaleString()} تومان
|
||||
</RoundedDiv>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
onClick={payHandler}
|
||||
className="h-9 w-32"
|
||||
>
|
||||
پرداخت
|
||||
</RoundedButton>
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-text-blue-light dark:text-text-blue-dark">
|
||||
{t("billboards.payment.title")}
|
||||
</h6>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{advertising && <MainBillboardCard billboard={advertising} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{getBillboardDisplayTypeLabel(t, advertising?.type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{t("billboards.payment.payableAmount", {
|
||||
amount: Number(price).toLocaleString(),
|
||||
})}
|
||||
</RoundedDiv>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
onClick={payHandler}
|
||||
className="h-9 w-32"
|
||||
>
|
||||
{t("billboards.payment.title")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<BillboardFormProvider>
|
||||
<Container>
|
||||
<MultiStepForm />
|
||||
</Container>
|
||||
</BillboardFormProvider>
|
||||
<LocalePageShell>
|
||||
<BillboardFormProvider>
|
||||
<Container>
|
||||
<MultiStepForm />
|
||||
</Container>
|
||||
</BillboardFormProvider>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<IAdvertising>();
|
||||
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(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 (
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#FF0000] ">
|
||||
پرداخت ناموفق
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/failed.svg`}
|
||||
className="pb-1 mb-5"
|
||||
/>
|
||||
<span> پرداخت شما با خطا مواجه شد. </span>
|
||||
<span> برای تایید درخواست پرداخت خود را کامل کنید</span>
|
||||
</div>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{advertising && <MainBillboardCard billboard={advertising} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{advertising?.type === "special"
|
||||
? "ویژه"
|
||||
: advertising?.type === "normal"
|
||||
? "نمایش ساده"
|
||||
: advertising?.type === "free"
|
||||
? "رایگان"
|
||||
: "برجسته"}
|
||||
: {Number(price).toLocaleString()} تومان
|
||||
</RoundedDiv>
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
مبلغ قابل پرداخت: {Number(price).toLocaleString()} تومان
|
||||
</RoundedDiv>
|
||||
<RoundedButton
|
||||
onClick={() => {
|
||||
if (type == "advertising-republish") {
|
||||
republishHandler();
|
||||
} else {
|
||||
payHandler();
|
||||
}
|
||||
}}
|
||||
variant="primary" className="w-32 h-9"
|
||||
>
|
||||
پرداخت مجدد
|
||||
</RoundedButton>
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#FF0000] ">
|
||||
{t("billboards.payment.failed")}
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/failed.svg`}
|
||||
className="pb-1 mb-5"
|
||||
/>
|
||||
<span>{t("billboards.payment.failedHint1")}</span>
|
||||
<span>{t("billboards.payment.failedHint2")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{advertising && <MainBillboardCard billboard={advertising} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{getBillboardDisplayTypeLabel(t, advertising?.type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{t("billboards.payment.payableAmount", {
|
||||
amount: Number(price).toLocaleString(),
|
||||
})}
|
||||
</RoundedDiv>
|
||||
<RoundedButton
|
||||
onClick={() => {
|
||||
if (type == "advertising-republish") {
|
||||
republishHandler();
|
||||
} else {
|
||||
payHandler();
|
||||
}
|
||||
}}
|
||||
variant="primary"
|
||||
className="w-32 h-9"
|
||||
>
|
||||
{t("billboards.payment.retry")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<IAdvertising>();
|
||||
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(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 (
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#17A600] ">
|
||||
پرداخت موفق
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/success.svg`}
|
||||
className="pb-1 mb-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{advertising && <MainBillboardCard billboard={advertising} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{advertising?.type === "special"
|
||||
? "ویژه"
|
||||
: advertising?.type === "normal"
|
||||
? "نمایش ساده"
|
||||
: advertising?.type === "free"
|
||||
? "رایگان"
|
||||
: "برجسته"}
|
||||
: {Number(price).toLocaleString()} تومان
|
||||
</RoundedDiv>
|
||||
<p className="my-5">
|
||||
آگهی شما در بیلبورد ثبت شد. پس از بررسی آگهی شما در بیلبورد منتشر
|
||||
خواهد شد.
|
||||
</p>
|
||||
<Link href={"/settings/my-billboards"}>
|
||||
<RoundedButton variant="primary" className="w-32 h-9">
|
||||
بیلورد من
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#17A600] ">
|
||||
{t("billboards.payment.success")}
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/success.svg`}
|
||||
className="pb-1 mb-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{advertising && <MainBillboardCard billboard={advertising} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{getBillboardDisplayTypeLabel(t, advertising?.type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
<p className="my-5">{t("billboards.payment.registeredMessage")}</p>
|
||||
<Link href={"/settings/my-billboards"}>
|
||||
<RoundedButton variant="primary" className="w-32 h-9">
|
||||
{t("billboards.payment.myBillboards")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 <Container className="py-20 text-center">آگهی یافت نشد</Container>;
|
||||
if (!profile) return <BillboardAdNotFound />;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* اضافه کردن H1 برای سبز شدن نمره سئو - مخفی از کاربر بصری، مرئی برای گوگل */}
|
||||
<h1 className="sr-only">
|
||||
{profile.title} - {profile.category?.name} در {profile.city?.name}، {profile.neighborhood?.name}
|
||||
</h1>
|
||||
|
||||
<AdProfileHead profile={profile} id={id} />
|
||||
<AdProfileContent profile={profile} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
return <AdvertisingProfileContent profile={profile} id={id} />;
|
||||
}
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 <Container className="py-20 text-center">آگهی یافت نشد</Container>;
|
||||
if (!profile) return <BillboardAdNotFound />;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* اضافه کردن H1 برای سبز شدن نمره سئو - مخفی از کاربر بصری، مرئی برای گوگل */}
|
||||
<h1 className="sr-only">
|
||||
{profile.title} - {profile.category?.name} در {profile.city?.name}، {profile.neighborhood?.name}
|
||||
</h1>
|
||||
|
||||
<AdProfileHead profile={profile} id={id} />
|
||||
<AdProfileContent profile={profile} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
return <AdvertisingProfileContent profile={profile} id={id} />;
|
||||
}
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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<Metadata> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Metadata> {
|
||||
return generateSeoPageMetadata("explore");
|
||||
}
|
||||
|
||||
export default function ExploreLayout({
|
||||
children,
|
||||
|
||||
@@ -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<ExploreFilterId>("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 (
|
||||
<>
|
||||
<Header />
|
||||
<Container className="pb-28">
|
||||
<h1 className="sr-only">
|
||||
{pageSeo.explore.title.replace(" | مدستاگرام", "")}
|
||||
</h1>
|
||||
<ExploreFilterBar
|
||||
activeFilter={activeFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
loadingLocation={loadingLocation}
|
||||
searchValue={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
onSearchSubmit={handleSearchSubmit}
|
||||
/>
|
||||
<ExploreGrid
|
||||
activeFilter={activeFilter}
|
||||
coords={coords}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</Container>
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<h1 className="sr-only">{exploreSeo.title.split("|")[0].trim()}</h1>
|
||||
<div className="pb-2 pt-1">
|
||||
<ExploreFilterBar
|
||||
activeFilter={activeFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
loadingLocation={loadingLocation}
|
||||
searchValue={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
onSearchSubmit={handleSearchSubmit}
|
||||
/>
|
||||
</div>
|
||||
<ExploreGrid
|
||||
activeFilter={activeFilter}
|
||||
coords={coords}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/explore" />
|
||||
</>
|
||||
);
|
||||
|
||||
39
src/app/global-error.tsx
Normal file
39
src/app/global-error.tsx
Normal file
@@ -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 (
|
||||
<html lang={lang} dir={dir}>
|
||||
<body className="flex min-h-screen flex-col items-center justify-center gap-4 bg-white px-6 text-center text-neutral-900">
|
||||
<p className="text-lg font-bold">{t("errors.appLoad")}</p>
|
||||
<p className="max-w-md text-sm text-neutral-600">{t("errors.safariIosHint")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
className="rounded-xl bg-[#0095f6] px-5 py-2.5 text-sm font-bold text-white"
|
||||
>
|
||||
{t("errors.retry")}
|
||||
</button>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<html lang="fa" dir="rtl" suppressHydrationWarning>
|
||||
<html lang={def.htmlLang} dir={def.direction} suppressHydrationWarning>
|
||||
<body className={`${iranSansFont.className} antialiased`}>
|
||||
<SiteJsonLd />
|
||||
<RegisterSW />
|
||||
<Layout>{children}</Layout>
|
||||
<ClientErrorBoundary>
|
||||
<Layout>{children}</Layout>
|
||||
</ClientErrorBoundary>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center gap-3 bg-background">
|
||||
<IOSSpinner size={32} color="#007aff" />
|
||||
<span className="text-sm text-neutral-500">در حال بارگذاری…</span>
|
||||
<span className="text-sm text-neutral-500">{translateCommon(lang, "common.loading")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
42
src/app/manifest.webmanifest/route.ts
Normal file
42
src/app/manifest.webmanifest/route.ts
Normal file
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<Metadata> {
|
||||
return generateSeoPageMetadata("newPost", { index: false });
|
||||
}
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -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<CreateMode, string> = {
|
||||
image: "عکس",
|
||||
video: "ویدئو",
|
||||
story: "استوری",
|
||||
};
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -31,9 +48,12 @@ function fileToBase64(file: File): Promise<string> {
|
||||
}
|
||||
|
||||
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<CreateMode>(
|
||||
@@ -45,7 +65,28 @@ export default function NewPostPage() {
|
||||
const [extraPreviews, setExtraPreviews] = useState<string[]>([""]);
|
||||
const [description, setDescription] = useState("");
|
||||
const [taggedUsers, setTaggedUsers] = useState<TaggedUser[]>([]);
|
||||
const [selectedPackages, setSelectedPackages] = useState<SelectedPostLink[]>(
|
||||
[]
|
||||
);
|
||||
const [selectedProjects, setSelectedProjects] = useState<SelectedPostLink[]>(
|
||||
[]
|
||||
);
|
||||
const [selectedBillboards, setSelectedBillboards] = useState<
|
||||
SelectedPostLink[]
|
||||
>([]);
|
||||
const [storyOverlays, setStoryOverlays] = useState<StoryTextOverlay[]>([]);
|
||||
const [videoCover, setVideoCover] = useState<File | null>(null);
|
||||
const [locationNoticeOpen, setLocationNoticeOpen] = useState(false);
|
||||
const [expertiseModalOpen, setExpertiseModalOpen] = useState(false);
|
||||
|
||||
const modeLabels: Record<CreateMode, string> = 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<HTMLInputElement>) => {
|
||||
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<User | undefined> => {
|
||||
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<boolean> => {
|
||||
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 (
|
||||
<LocalePageShell>
|
||||
<div className="mx-auto flex min-h-[calc(100dvh-8rem)] max-w-lg flex-col bg-background">
|
||||
{/* Header — Instagram style */}
|
||||
<header className="sticky top-0 z-10 flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
@@ -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")}
|
||||
>
|
||||
<BoldIcon name="arrow-right-3" size={22} className="block dark:invert" />
|
||||
</button>
|
||||
<span className="text-base font-semibold">
|
||||
{isStory ? "استوری جدید" : "پست جدید"}
|
||||
{isStory ? t("posts.newStory") : t("posts.newPost")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={publish}
|
||||
onClick={handleShareClick}
|
||||
disabled={loading}
|
||||
className="text-sm font-semibold text-[#0095f6] disabled:opacity-40"
|
||||
>
|
||||
{loading ? "…" : "اشتراک"}
|
||||
{loading ? "…" : t("posts.shareButton")}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -245,7 +381,7 @@ export default function NewPostPage() {
|
||||
: "text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{MODE_LABELS[m]}
|
||||
{modeLabels[m]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -260,7 +396,7 @@ export default function NewPostPage() {
|
||||
onChange={setStoryOverlays}
|
||||
/>
|
||||
<label className="mx-auto rounded-lg bg-neutral-100 px-4 py-2 text-center text-xs text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
تغییر فایل
|
||||
{t("posts.changeFile")}
|
||||
<input
|
||||
type="file"
|
||||
accept={acceptAttr}
|
||||
@@ -305,13 +441,13 @@ export default function NewPostPage() {
|
||||
<BoldIcon name="gallery-add" size={48} className="block opacity-60 dark:invert" />
|
||||
<span className="text-sm">
|
||||
{isStory
|
||||
? "عکس یا ویدئو استوری را انتخاب کنید"
|
||||
? t("posts.pickStoryMedia")
|
||||
: mode === "video"
|
||||
? "ویدئو را انتخاب کنید"
|
||||
: "عکس را انتخاب کنید"}
|
||||
? t("posts.pickVideo")
|
||||
: t("posts.pickImage")}
|
||||
</span>
|
||||
<span className="rounded-lg bg-[#0095f6] px-4 py-2 text-sm font-semibold text-white">
|
||||
انتخاب از گالری
|
||||
{t("posts.pickFromGallery")}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
@@ -323,7 +459,7 @@ export default function NewPostPage() {
|
||||
)}
|
||||
{preview && (
|
||||
<label className="absolute bottom-4 rounded-lg bg-black/60 px-4 py-2 text-xs text-white">
|
||||
تغییر فایل
|
||||
{t("posts.changeFile")}
|
||||
<input type="file" accept={acceptAttr} className="hidden" onChange={onPickMain} />
|
||||
</label>
|
||||
)}
|
||||
@@ -359,30 +495,58 @@ export default function NewPostPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "video" && preview && file?.type.startsWith("video/") && (
|
||||
<VideoCoverPicker videoUrl={preview} onCoverChange={setVideoCover} />
|
||||
)}
|
||||
|
||||
{/* Caption — not for story */}
|
||||
{!isStory && (
|
||||
<div className="space-y-3 border-t border-neutral-200 p-4 dark:border-neutral-800">
|
||||
<textarea
|
||||
placeholder="کپشن بنویسید…"
|
||||
placeholder={t("posts.captionPlaceholder")}
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length <= 1000) setDescription(e.target.value);
|
||||
}}
|
||||
rows={3}
|
||||
className="w-full resize-none bg-transparent text-sm outline-none placeholder:text-neutral-400"
|
||||
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"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-neutral-400">
|
||||
<span>{description.length}/1000</span>
|
||||
<span>
|
||||
{captionLength}/{POST_CAPTION_MAX_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
<TagUsersPicker selected={taggedUsers} onChange={setTaggedUsers} max={50} />
|
||||
<PostLinkPicker
|
||||
selectedPackages={selectedPackages}
|
||||
selectedProjects={selectedProjects}
|
||||
selectedBillboards={selectedBillboards}
|
||||
onPackagesChange={setSelectedPackages}
|
||||
onProjectsChange={setSelectedProjects}
|
||||
onBillboardsChange={setSelectedBillboards}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isStory && (
|
||||
<p className="px-4 pb-6 text-center text-xs text-neutral-500">
|
||||
استوری شما ۲۴ ساعت نمایش داده میشود و سپس بهطور خودکار حذف میگردد.
|
||||
{t("posts.storyExpiryHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<PostLocationNoticeModal
|
||||
isOpen={locationNoticeOpen}
|
||||
onNo={() => {
|
||||
dismissLocationNotice();
|
||||
void publish();
|
||||
}}
|
||||
onYes={dismissLocationNotice}
|
||||
/>
|
||||
|
||||
<PostExpertiseRequiredModal
|
||||
isOpen={expertiseModalOpen}
|
||||
onClose={() => setExpertiseModalOpen(false)}
|
||||
returnPath={returnPath}
|
||||
/>
|
||||
</div>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<User>();
|
||||
const [selectedType, setSelectedType] = useState("normal");
|
||||
const [typeList, setTypeList] = useState<IOfferType[] | null>(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) {
|
||||
<Container>
|
||||
<div className="mx-3 mb-10">
|
||||
|
||||
<PageTitle>پرداخت</PageTitle>
|
||||
<PageTitle>{t("offerPage.paymentTitle")}</PageTitle>
|
||||
<p className="text-center">
|
||||
شما فقط 1 بار میتوانید درخواست رایگان ثبت کنید
|
||||
{t("offerPage.freeOnceHint")}
|
||||
</p>
|
||||
<form
|
||||
className="flex flex-col gap-2 w-full items-center text-sm"
|
||||
@@ -174,7 +174,7 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
width={21}
|
||||
height={21}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/medal-star.svg`}
|
||||
src={`/images/icons/medal-star.png`}
|
||||
className="pb-1"
|
||||
/>
|
||||
</div>
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
@@ -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")
|
||||
: ""}
|
||||
</RoundedDiv>
|
||||
</div>
|
||||
@@ -210,7 +210,7 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
})}
|
||||
|
||||
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
|
||||
ثبت درخواست
|
||||
{t("offerPage.submitRequest")}
|
||||
</RoundedButton>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-20 line-clamp-2 mb-10 text-[#FF0000] ">
|
||||
پرداخت ناموفق
|
||||
{t("offerPage.paymentFailed.title")}
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt={"verify icon"}
|
||||
alt="failed icon"
|
||||
src={`/images/icons/failed.svg`}
|
||||
className="pb-1 mb-8"
|
||||
/>
|
||||
<span> پرداخت شما با خطا مواجه شد. </span>
|
||||
<span>{t("offerPage.paymentFailed.message")}</span>
|
||||
</div>
|
||||
<Link
|
||||
className="flex items-center flex-col mt-8"
|
||||
href={`/offer/${userId}`}
|
||||
>
|
||||
<RoundedButton variant="primary" className="w-32 h-9">
|
||||
پرداخت مجدد
|
||||
{t("offerPage.paymentFailed.retry")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</Container>
|
||||
|
||||
@@ -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 (
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-10 line-clamp-2 mb-4 text-[#17A600] ">
|
||||
پرداخت موفق
|
||||
{t("offerPage.paymentSuccess.title")}
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt={"verify icon"}
|
||||
alt="success icon"
|
||||
src={`/images/icons/success.svg`}
|
||||
className="pb-1 mb-5"
|
||||
/>
|
||||
@@ -43,17 +45,19 @@ function SuccessBillboard() {
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<p className="my-5">
|
||||
یک درخواست همکاری برای کاربر {userDetail?.user_name} ثبت شد
|
||||
{t("offerPage.paymentSuccess.message", {
|
||||
username: userDetail?.user_name ?? "",
|
||||
})}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Link href={`/settings/chats/${userDetail?.user_name}/${userId}`}>
|
||||
<RoundedButton variant="primary" className="w-32 h-9">
|
||||
ارسال پیام
|
||||
{t("offerPage.paymentSuccess.sendMessage")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
<Link href={"/"}>
|
||||
<RoundedButton variant="primary" className="w-32 h-9">
|
||||
بعدا
|
||||
{t("offerPage.paymentSuccess.later")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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<Metadata> {
|
||||
([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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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<string, string>;
|
||||
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 (
|
||||
<Container>
|
||||
{/* اضافه کردن H1 مخفی برای بهبود سئو */}
|
||||
<h1 className="sr-only">
|
||||
{filters.search
|
||||
? `نتایج جستجوی مدستاگرام برای: ${filters.search}`
|
||||
: "جستجوی تخصصی در پلتفرم مد و زیبایی مدستاگرام"}
|
||||
</h1>
|
||||
|
||||
<Container className="pb-28">
|
||||
<SearchFilter />
|
||||
<InfiniteSearch
|
||||
initialData={initialData}
|
||||
filters={filters}
|
||||
token={token}
|
||||
/>
|
||||
<InfiniteSearch token={token} initialData={initialData} filters={filters} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Course[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [pagination, setPagination] = useState<PaginationData>({
|
||||
@@ -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 (
|
||||
<div className="min-h-screen container mx-auto px-4 py-8">
|
||||
<AcademyListSkeleton count={4} />
|
||||
</div>
|
||||
<LocalePageShell>
|
||||
<div className="min-h-screen container mx-auto px-4 py-8">
|
||||
<AcademyListSkeleton count={4} />
|
||||
</div>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white dark:bg-neutral-950">
|
||||
{/* هدر صفحه */}
|
||||
<div className="relative bg-gradient-to-r from-purple-400 via-purple-600 to-pink-600 dark:from-purple-600 dark:via-purple-800 dark:to-pink-800">
|
||||
<div className="absolute inset-0 bg-black/10"></div>
|
||||
<div className="relative container mx-auto px-4 py-12 md:py-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center"
|
||||
>
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-4">
|
||||
دورههای خریداری شده من
|
||||
</h1>
|
||||
<p className="text-lg text-white/90 max-w-2xl mx-auto">
|
||||
به جمعآوری دورههای خود نگاهی بیندازید و یادگیری را ادامه دهید
|
||||
</p>
|
||||
<div className="inline-flex items-center gap-2 mt-6 px-4 py-2 bg-white/20 backdrop-blur-sm rounded-full">
|
||||
<svg
|
||||
className="w-5 h-5 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-white font-medium">
|
||||
{pagination.total} دوره خریداری شده
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
{/* منحنی پایین هدر */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg
|
||||
className="w-full h-12 text-white dark:text-neutral-950"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 1440 54"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M0 22L120 16.7C240 11 480 0 720 0C960 0 1200 11 1320 16.7L1440 22V54H1320C1200 54 960 54 720 54C480 54 240 54 120 54H0V22Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* محتوای اصلی */}
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
{courses.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center py-20"
|
||||
>
|
||||
<div className="relative w-48 h-48 mx-auto mb-8">
|
||||
<Image
|
||||
src="/images/empty-courses.svg"
|
||||
alt="دورهای وجود ندارد"
|
||||
fill
|
||||
className="object-contain"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = "/images/empty-box.png";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-3">
|
||||
هنوز دورهای خریداری نکردهاید
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
اولین دوره خود را خریداری کنید و مسیر یادگیری را شروع کنید
|
||||
</p>
|
||||
<a
|
||||
href="/academy"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-xl hover:shadow-lg transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
مشاهده دورهها
|
||||
</a>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
{/* آمار دورهها */}
|
||||
<LocalePageShell>
|
||||
<div className="min-h-screen bg-white dark:bg-neutral-950">
|
||||
<div className="relative bg-gradient-to-r from-purple-400 via-purple-600 to-pink-600 dark:from-purple-600 dark:via-purple-800 dark:to-pink-800">
|
||||
<div className="absolute inset-0 bg-black/10"></div>
|
||||
<div className="relative container mx-auto px-4 py-12 md:py-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8"
|
||||
className="text-center"
|
||||
>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-md border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
تعداد کل دورهها
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{pagination.total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-md border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
صفحه جاری
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{pagination.page} / {pagination.totalPages}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-purple-600 dark:text-purple-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-md border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
نمایش در هر صفحه
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{pagination.limit}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-pink-100 dark:bg-pink-900 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-pink-600 dark:text-pink-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-4">
|
||||
{t("settings.academy.myCourses.title")}
|
||||
</h1>
|
||||
<p className="text-lg text-white/90 max-w-2xl mx-auto">
|
||||
{t("settings.academy.myCourses.subtitle")}
|
||||
</p>
|
||||
<div className="inline-flex items-center gap-2 mt-6 px-4 py-2 bg-white/20 backdrop-blur-sm rounded-full">
|
||||
<svg
|
||||
className="w-5 h-5 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-white font-medium">
|
||||
{t("settings.academy.myCourses.purchasedCount", {
|
||||
count: pagination.total,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg
|
||||
className="w-full h-12 text-white dark:text-neutral-950"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 1440 54"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M0 22L120 16.7C240 11 480 0 720 0C960 0 1200 11 1320 16.7L1440 22V54H1320C1200 54 960 54 720 54C480 54 240 54 120 54H0V22Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* لیست دورهها */}
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-6"
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
{courses.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center py-20"
|
||||
>
|
||||
<div className="relative w-48 h-48 mx-auto mb-8">
|
||||
<Image
|
||||
src="/images/empty-courses.svg"
|
||||
alt={t("settings.academy.myCourses.emptyAlt")}
|
||||
fill
|
||||
className="object-contain"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = "/images/empty-box.png";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-3">
|
||||
{t("settings.academy.myCourses.emptyTitle")}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
{t("settings.academy.myCourses.emptyDescription")}
|
||||
</p>
|
||||
<a
|
||||
href="/academy"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-xl hover:shadow-lg transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
{courses.map((course, index) => (
|
||||
<motion.div
|
||||
key={course._id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<MainModelCard postData={course} />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* پیجینیشن */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
{t("settings.academy.myCourses.browseCourses")}
|
||||
</a>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mt-12 flex justify-center"
|
||||
transition={{ duration: 0.5 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8"
|
||||
>
|
||||
<div className="flex items-center gap-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg p-2 border border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={!pagination.hasPrevPage}
|
||||
className={`
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-md border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{t("settings.academy.myCourses.totalCourses")}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{pagination.total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-md border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{t("settings.academy.myCourses.currentPage")}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{pagination.page} / {pagination.totalPages}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-purple-600 dark:text-purple-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-md border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{t("settings.academy.myCourses.perPage")}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{pagination.limit}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-pink-100 dark:bg-pink-900 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-pink-600 dark:text-pink-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{courses.map((course, index) => (
|
||||
<motion.div
|
||||
key={course._id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<MainModelCard postData={course} />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{pagination.totalPages > 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mt-12 flex justify-center"
|
||||
>
|
||||
<div className="flex items-center gap-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg p-2 border border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => 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"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map(
|
||||
(page) => {
|
||||
// نمایش حداکثر 5 صفحه
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from(
|
||||
{ length: pagination.totalPages },
|
||||
(_, i) => i + 1
|
||||
).map((page) => {
|
||||
if (
|
||||
page === 1 ||
|
||||
page === pagination.totalPages ||
|
||||
@@ -362,7 +365,6 @@ export default function PurchasedCoursesPage() {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
// نمایش نقطه چین
|
||||
if (
|
||||
page === currentPage - 2 ||
|
||||
page === currentPage + 2
|
||||
@@ -377,14 +379,13 @@ export default function PurchasedCoursesPage() {
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={!pagination.hasNextPage}
|
||||
className={`
|
||||
<button
|
||||
onClick={() => 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"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* دکمه بازگشت به بالا */}
|
||||
{courses.length > 3 && (
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
{courses.length > 3 && (
|
||||
<button
|
||||
onClick={() =>
|
||||
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")}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof storeSchema>;
|
||||
type StoreFormValues = z.infer<ReturnType<typeof buildStoreSchema>>;
|
||||
|
||||
|
||||
|
||||
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<string>("");
|
||||
const [avatar, setAvatar] = useState<string | null>(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 (
|
||||
<LocalePageShell>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full p-6 max-w-4xl mx-auto mb-32"
|
||||
className="w-full p-6 max-w-4xl mx-auto pb-[calc(6rem+env(safe-area-inset-bottom))]"
|
||||
dir={t("dir") || "rtl"}
|
||||
>
|
||||
<h2 className="text-3xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent mb-6">
|
||||
{"مشخصات آموزشگاه"}
|
||||
{t("settings.academy.storeProfile.title")}
|
||||
</h2>
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -227,19 +270,16 @@ export default function StoreSettings() {
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-8"
|
||||
>
|
||||
<ScrollArea
|
||||
dir="rtl"
|
||||
className="max-h-[80vh] p-4 border flex flex-col"
|
||||
>
|
||||
<div dir="rtl" className="flex flex-col border p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{"نام آموزشگاه"}</FormLabel>
|
||||
<FormLabel>{t("settings.academy.storeProfile.name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"نام آموزشگاه"} {...field} />
|
||||
<Input placeholder={t("settings.academy.storeProfile.name")} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -250,10 +290,40 @@ export default function StoreSettings() {
|
||||
name="sheba"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{"شبا"}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"شبا"} {...field} />
|
||||
</FormControl>
|
||||
<FormLabel>{t("settings.academy.storeProfile.sheba")}</FormLabel>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormControl>
|
||||
<div className="relative flex-1">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm font-semibold text-muted-foreground">
|
||||
IR
|
||||
</span>
|
||||
<Input
|
||||
placeholder={t("settings.academy.storeProfile.shebaPlaceholder")}
|
||||
maxLength={24}
|
||||
inputMode="numeric"
|
||||
className={cn(
|
||||
"pl-10 dir-ltr",
|
||||
shebaValid &&
|
||||
"border-green-500 focus-visible:ring-green-500"
|
||||
)}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const digits = e.target.value
|
||||
.replace(/\D/g, "")
|
||||
.slice(0, 24);
|
||||
field.onChange(digits);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
{bankName ? (
|
||||
<span className="shrink-0 text-xs font-semibold text-green-600 dark:text-green-400">
|
||||
{bankName}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -265,10 +335,10 @@ export default function StoreSettings() {
|
||||
name="bio"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="mt-1.5">{"بیو"}</FormLabel>
|
||||
<FormLabel className="mt-1.5">{t("settings.academy.storeProfile.bio")}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={"درباره فروشگاه خود بنویسید"}
|
||||
placeholder={t("settings.academy.storeProfile.bioPlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -276,9 +346,65 @@ export default function StoreSettings() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Separator className="mb-3" />
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-border-primary-light p-4">
|
||||
<FormLabel className="text-base">{t("settings.academy.storeProfile.contactOptional")}</FormLabel>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contact_mobile"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder={t("settings.billboards.mobile")} maxLength={11} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contact_telegram"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder={t("settings.billboards.telegram")} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contact_whatsapp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder={t("settings.billboards.whatsapp")} maxLength={11} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contact_instagram"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder={t("settings.billboards.instagram")} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="mb-3" />
|
||||
<FormItem>
|
||||
<FormLabel className="mt-1.5">{"عکس پروفایل"}</FormLabel>
|
||||
<FormLabel className="mt-1.5">{t("settings.academy.storeProfile.profileImage")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="fileInput"
|
||||
@@ -300,7 +426,7 @@ export default function StoreSettings() {
|
||||
</FormItem>
|
||||
<Separator className="mb-3" />
|
||||
<FormItem>
|
||||
<FormLabel className="mt-1.5">{"تگ های فروشگاه"}</FormLabel>
|
||||
<FormLabel className="mt-1.5">{t("settings.academy.storeProfile.storeTags")}</FormLabel>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{tagFields.map((tag, index) => (
|
||||
<Badge key={tag.id}>
|
||||
@@ -313,7 +439,7 @@ export default function StoreSettings() {
|
||||
</div>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Input
|
||||
placeholder={"تگ"}
|
||||
placeholder={tagPlaceholder}
|
||||
onKeyDown={(e) => {
|
||||
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")}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<Separator className="mb-3" />
|
||||
|
||||
<motion.div
|
||||
className="flex justify-center items-center gap-4 w-full"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="flex w-full justify-center pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="bg-gradient-to-r from-primary w-11/12 to-primary/80"
|
||||
className={cn(btnPrimary, "h-10 w-11/12")}
|
||||
>
|
||||
{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"
|
||||
/>
|
||||
</svg>
|
||||
"در حال ثبت..."
|
||||
{t("settings.academy.storeProfile.submitting")}
|
||||
</>
|
||||
) : (
|
||||
"ثبت"
|
||||
t("settings.academy.storeProfile.submit")
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</motion.div>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<LocalePageShell>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<div className="min-h-screen text-foreground">
|
||||
<div className="container mx-auto p-6 space-y-8">
|
||||
@@ -239,14 +246,14 @@ const WalletDashboard = () => {
|
||||
>
|
||||
<Card className="text-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">کل فروش</CardTitle>
|
||||
<CardTitle className="text-lg">{t("settings.academy.wallet.totalSales")}</CardTitle>
|
||||
<CardDescription className="dark:text-green-100 text-green-500">
|
||||
مجموع فروشهای موفق
|
||||
{t("settings.academy.wallet.totalSalesDesc")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent >
|
||||
<div className="text-3xl font-bold dark:text-white text-neutral-700">
|
||||
{(stats.sales + stats.settled).toLocaleString()} تومان
|
||||
{(stats.sales + stats.settled).toLocaleString(dateLocale)} {t("settings.toman")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -260,14 +267,14 @@ const WalletDashboard = () => {
|
||||
>
|
||||
<Card className="text-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">تسویه شده</CardTitle>
|
||||
<CardTitle className="text-lg">{t("settings.academy.wallet.settled")}</CardTitle>
|
||||
<CardDescription className="dark:text-purple-100 text-purple-500">
|
||||
مبالغ پرداخت شده به حساب
|
||||
{t("settings.academy.wallet.settledDesc")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold dark:text-white text-neutral-700">
|
||||
{stats.settled.toLocaleString()} تومان
|
||||
{stats.settled.toLocaleString(dateLocale)} {t("settings.toman")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -277,7 +284,7 @@ const WalletDashboard = () => {
|
||||
{/* جداول تراکنشها */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-4">
|
||||
<CardTitle>گزارش تراکنشها</CardTitle>
|
||||
<CardTitle>{t("settings.academy.wallet.transactionsReport")}</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={filterType}
|
||||
@@ -286,12 +293,12 @@ const WalletDashboard = () => {
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="فیلتر بر اساس نوع" />
|
||||
<SelectValue placeholder={t("settings.academy.wallet.filterByType")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه</SelectItem>
|
||||
<SelectItem value="expense">فروش</SelectItem>
|
||||
<SelectItem value="withdraw">پرداخت شده</SelectItem>
|
||||
<SelectItem value="all">{t("settings.academy.wallet.all")}</SelectItem>
|
||||
<SelectItem value="expense">{t("settings.academy.wallet.sales")}</SelectItem>
|
||||
<SelectItem value="withdraw">{t("settings.academy.wallet.paidOut")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -299,7 +306,7 @@ const WalletDashboard = () => {
|
||||
<CardContent>
|
||||
{filteredTransactions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
هیچ تراکنشی یافت نشد
|
||||
{t("settings.academy.wallet.noTransactions")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
@@ -308,10 +315,10 @@ const WalletDashboard = () => {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="text-right">شرح</TableHead>
|
||||
<TableHead className="text-right">مبلغ</TableHead>
|
||||
<TableHead className="text-right">تاریخ</TableHead>
|
||||
<TableHead className="text-right">وضعیت</TableHead>
|
||||
<TableHead className="text-right">{t("settings.academy.wallet.description")}</TableHead>
|
||||
<TableHead className="text-right">{t("settings.academy.wallet.amount")}</TableHead>
|
||||
<TableHead className="text-right">{t("settings.academy.wallet.date")}</TableHead>
|
||||
<TableHead className="text-right">{t("settings.status")}</TableHead>
|
||||
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -319,7 +326,7 @@ const WalletDashboard = () => {
|
||||
{filteredTransactions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8">
|
||||
هیچ تراکنشی یافت نشد
|
||||
{t("settings.academy.wallet.noTransactions")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -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")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
@@ -365,7 +374,7 @@ const WalletDashboard = () => {
|
||||
<div className="md:hidden space-y-3">
|
||||
{filteredTransactions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
هیچ تراکنشی یافت نشد
|
||||
{t("settings.academy.wallet.noTransactions")}
|
||||
</div>
|
||||
) : (
|
||||
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")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -412,6 +423,7 @@ const WalletDashboard = () => {
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</LocalePageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<NodeJS.Timeout | null>(null);
|
||||
const activeReplyRef = useRef<ChatMessage | null>(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 (
|
||||
<LocalePageShell>
|
||||
<Container className="chat-page-bg flex h-[100dvh] max-h-[100dvh] flex-col overflow-hidden !px-0">
|
||||
<ChatHeader
|
||||
user={userTwoDetail}
|
||||
@@ -484,7 +597,9 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
onToggleDeleteSelect={toggleDeleteSelect}
|
||||
onExpirePending={handleExpirePending}
|
||||
onIncomingMessage={handleIncomingMessage}
|
||||
onPruneResolvedPending={pruneResolvedPending}
|
||||
onScrollToMessageReady={(fn) => {
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
<MessageInput
|
||||
@@ -525,9 +642,12 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
setReplyingTo(null);
|
||||
activeReplyRef.current = null;
|
||||
}}
|
||||
onReplyPreviewClick={() => {
|
||||
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) {
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Metadata> {
|
||||
return generateSeoPageMetadata("settingsChats", { index: false });
|
||||
}
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
return <ChatSocketProvider>{children}</ChatSocketProvider>;
|
||||
}
|
||||
|
||||
@@ -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<string, { hasStory: boolean; hasUnviewed: boolean }>();
|
||||
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 (
|
||||
<Container>
|
||||
<PageTitle>پیام ها</PageTitle>
|
||||
<div className="px-4 text-xs md:text-sm">
|
||||
<UserDetails />
|
||||
<div className="relative mt-5 w-full">
|
||||
<RoundedInput
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") setSearch(searchText);
|
||||
}}
|
||||
placeholder="جستجو..."
|
||||
/>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<ChatListSkeleton />
|
||||
) : isEmpty ? (
|
||||
<p className="py-12 text-center text-neutral-500">
|
||||
هنوز مکالمهای ندارید.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-5 flex flex-col gap-3">
|
||||
{data?.pages.map((page) =>
|
||||
page.filteredUsersData?.map((item: IMessage) => (
|
||||
<Link
|
||||
key={item._id}
|
||||
href={`/settings/chats/${item?.user_name}/${item?._id}`}
|
||||
className="flex items-center justify-between rounded-2xl border border-gray-100 p-3 dark:border-gray-800"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{item.profile_image ? (
|
||||
<Image
|
||||
src={buildStorageUrl(item.profile_image)}
|
||||
width={48}
|
||||
height={48}
|
||||
alt={item.user_name}
|
||||
className="rounded-2xl"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-12 w-12 rounded-2xl bg-neutral-200" />
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-1 font-semibold">
|
||||
{item.display_name ||
|
||||
`${item.first_name} ${item.last_name}`.trim()}
|
||||
<VerificationBadge isVerified={item.is_verified} />
|
||||
</span>
|
||||
<span className="text-neutral-500">{item.user_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{item.unread_messages_count ? (
|
||||
<span className="rounded-full bg-[#387E65] px-2 py-0.5 text-xs text-white">
|
||||
{item.unread_messages_count}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("chats.title")}</PageTitle>
|
||||
<div className="text-xs md:text-sm">
|
||||
<UserDetails />
|
||||
<div className="relative mt-5 w-full">
|
||||
<RoundedInput
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") setSearch(searchText);
|
||||
}}
|
||||
placeholder={t("chats.searchPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
{isLoading ? (
|
||||
<ChatListSkeleton />
|
||||
) : (
|
||||
<div className="mt-5 flex flex-col gap-1">
|
||||
<Link
|
||||
href="/settings/chats/rooms"
|
||||
className="flex items-center justify-between py-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[#0095f6]/15">
|
||||
<BoldIcon name="messages-2" size={24} className="text-[#0095f6]" tinted />
|
||||
</div>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<span className="truncate font-semibold">{t("chats.chatRoom")}</span>
|
||||
<span className="truncate text-[11px] text-neutral-500">
|
||||
{t("chats.chatRoomDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{isEmpty ? (
|
||||
<p className="py-8 text-center text-neutral-500">
|
||||
{t("chats.empty")}
|
||||
</p>
|
||||
) : (
|
||||
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 (
|
||||
<Link
|
||||
key={item._id}
|
||||
href={`/settings/chats/${item?.user_name}/${item?._id}`}
|
||||
className="flex items-center justify-between py-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="relative shrink-0">
|
||||
<StoryRing
|
||||
hasStory={Boolean(storyMeta?.hasStory)}
|
||||
hasUnviewed={Boolean(storyMeta?.hasUnviewed)}
|
||||
avatarSize={48}
|
||||
>
|
||||
<ProfileAvatar
|
||||
src={item.profile_image}
|
||||
alt={item.user_name}
|
||||
size="sm"
|
||||
rounded="full"
|
||||
className="!h-12 !w-12"
|
||||
/>
|
||||
</StoryRing>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-white dark:border-neutral-900",
|
||||
isOnline ? "bg-[#22c55e]" : "bg-neutral-400"
|
||||
)}
|
||||
title={
|
||||
isOnline
|
||||
? onlineLabel
|
||||
: item.last_online || offlineLabel
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<span className="truncate font-semibold">
|
||||
{item.display_name ||
|
||||
`${item.first_name} ${item.last_name}`.trim()}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-1 truncate text-neutral-500">
|
||||
<span dir="ltr" className="truncate">
|
||||
{item.user_name}
|
||||
</span>
|
||||
<VerificationBadge isVerified={item.is_verified} />
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px]",
|
||||
isOnline
|
||||
? "text-[#22c55e]"
|
||||
: "text-neutral-400 dark:text-neutral-500"
|
||||
)}
|
||||
>
|
||||
{resolveOnlineLabel(
|
||||
item.last_online,
|
||||
onlineLabel,
|
||||
offlineLabel
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1 pr-2">
|
||||
{item.unread_messages_count ? (
|
||||
<span className="rounded-full bg-[#387E65] px-2 py-0.5 text-xs text-white">
|
||||
{item.unread_messages_count}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
714
src/app/settings/chats/rooms/[roomId]/page.tsx
Normal file
714
src/app/settings/chats/rooms/[roomId]/page.tsx
Normal file
@@ -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<string, RoomMessage>();
|
||||
|
||||
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<Map<string, HTMLDivElement>>;
|
||||
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 (
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) messageRefs.current.set(msg._id, el);
|
||||
else messageRefs.current.delete(msg._id);
|
||||
}}
|
||||
className={cn("flex w-full", isMine ? "justify-end" : "justify-start")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[min(85%,320px)]",
|
||||
isMine ? "items-end" : "items-start"
|
||||
)}
|
||||
>
|
||||
{!isMine ? (
|
||||
<Link
|
||||
href={`/users/${msg.sender?.user_name ?? ""}`}
|
||||
className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold text-[#0095f6]"
|
||||
>
|
||||
<ProfileAvatar
|
||||
src={msg.sender?.profile_image}
|
||||
alt={senderName}
|
||||
size="xxs"
|
||||
rounded="full"
|
||||
className="h-5 w-5 shrink-0"
|
||||
/>
|
||||
<span>{senderName}</span>
|
||||
<VerificationBadge isVerified={msg.sender?.is_verified} />
|
||||
</Link>
|
||||
) : null}
|
||||
{msg.replyTo ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onScrollToReply}
|
||||
className="mb-1 w-full rounded-lg border-r-2 border-[#0095f6] bg-black/5 px-2 py-1 text-right text-[11px] dark:bg-white/10"
|
||||
>
|
||||
<span className="font-semibold text-[#0095f6]">
|
||||
{msg.replyTo.senderName}
|
||||
</span>
|
||||
<p className="truncate">{msg.replyTo.content}</p>
|
||||
</button>
|
||||
) : null}
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-y-0 flex w-11 items-center justify-center text-[#0095f6]",
|
||||
isMine ? "right-0" : "left-0"
|
||||
)}
|
||||
style={{ opacity: replyOpacity, scale: replyScale }}
|
||||
>
|
||||
<ChatBoldIcon
|
||||
name="reply"
|
||||
size={20}
|
||||
className={cn("text-current", !isMine && "-scale-x-100")}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
{...restDragProps}
|
||||
onDragStart={swipeDragStart}
|
||||
className="relative z-[1] w-full"
|
||||
>
|
||||
<div
|
||||
data-chat-bubble
|
||||
{...longPressHandlers}
|
||||
onClick={() => {
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<p className="chat-message-text select-text whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
<span className="mt-1 block text-[10px] opacity-70">
|
||||
{msg.createdAt}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
{reactionGroups.length > 0 ? (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{reactionGroups.map(({ emoji, count }) => (
|
||||
<span
|
||||
key={emoji}
|
||||
className="rounded-full bg-black/10 px-2 py-0.5 text-xs dark:bg-white/10"
|
||||
>
|
||||
{emoji}
|
||||
{count > 1 ? ` ${count}` : ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<RoomDetail | null>(null);
|
||||
const [messages, setMessages] = useState<RoomMessage[]>([]);
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [replyingTo, setReplyingTo] = useState<RoomMessage | null>(null);
|
||||
const [selectedMessage, setSelectedMessage] = useState<RoomMessage | null>(null);
|
||||
const [actionMode, setActionMode] = useState(false);
|
||||
const [showAddMembers, setShowAddMembers] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const headerRef = useRef<HTMLElement | null>(null);
|
||||
const messageRefs = useRef(new Map<string, HTMLDivElement>());
|
||||
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 (
|
||||
<LocalePageShell>
|
||||
<Container className="chat-page-bg flex h-[100dvh] max-h-[100dvh] flex-col overflow-hidden !px-0">
|
||||
<header
|
||||
ref={headerRef}
|
||||
className="fixed left-0 right-0 top-0 z-40 border-b border-white/10 bg-black/20 px-3 pb-3 pt-[calc(0.75rem+env(safe-area-inset-top))] backdrop-blur-md"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-lg items-center gap-3">
|
||||
<Link
|
||||
href="/settings/chats/rooms"
|
||||
className="gentle-transition flex h-10 w-10 items-center justify-center rounded-full bg-white/10 active:scale-95"
|
||||
aria-label={t("chats.actions.back")}
|
||||
>
|
||||
<BoldIcon name="arrow-right-2" size={20} tinted className="text-white" />
|
||||
</Link>
|
||||
<div className="min-w-0 flex flex-1 items-center gap-3">
|
||||
<ProfileAvatar
|
||||
src={room?.image}
|
||||
alt={room?.title ?? t("chats.chatRoom")}
|
||||
size="sm"
|
||||
rounded="full"
|
||||
className="h-10 w-10 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="truncate text-base font-bold text-white">
|
||||
{room?.title ?? t("chats.chatRoom")}
|
||||
</h1>
|
||||
{room?.description ? (
|
||||
<p className="truncate text-[11px] text-white/70">
|
||||
{room.description}
|
||||
</p>
|
||||
) : null}
|
||||
{room?.onlineCount != null ? (
|
||||
<p className="text-[10px] text-[#22c55e]">
|
||||
{t("chats.onlineCount", { count: room.onlineCount })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{canAddMembers ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddMembers(true)}
|
||||
className="gentle-transition flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 active:scale-95"
|
||||
aria-label={t("chats.aria.addUser")}
|
||||
>
|
||||
<BoldIcon name="user-add" size={20} tinted className="text-white" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{room?.members?.length ? (
|
||||
<div className="mx-auto mt-3 flex w-full max-w-lg gap-2 overflow-x-auto pb-1">
|
||||
{room.members.slice(0, 12).map((member) => (
|
||||
<Link
|
||||
key={member._id}
|
||||
href={`/users/${member.user_name}`}
|
||||
className="flex shrink-0 flex-col items-center gap-1"
|
||||
title={member.user_name}
|
||||
>
|
||||
<ProfileAvatar
|
||||
src={member.profile_image}
|
||||
alt={member.user_name || ""}
|
||||
size="sm"
|
||||
rounded="full"
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<span className="max-w-[56px] truncate text-[10px] text-white/80">
|
||||
{member.user_name}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
dir="rtl"
|
||||
className="chat-thread-messages custom-scrollbar relative mx-auto flex min-h-0 w-full max-w-lg flex-1 flex-col overflow-y-auto px-3"
|
||||
style={{
|
||||
paddingTop: headerOffset,
|
||||
paddingBottom: `calc(5.5rem + env(safe-area-inset-bottom, 0px))`,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex flex-1 items-center justify-center py-20">
|
||||
<IOSSpinner size={28} />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
{t("chats.threadEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 pb-2">
|
||||
{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 (
|
||||
<RoomMessageRow
|
||||
key={msg._id}
|
||||
msg={msg}
|
||||
isMine={isMine}
|
||||
senderName={senderName}
|
||||
reactionGroups={reactionGroups}
|
||||
messageRefs={messageRefs}
|
||||
onOpenAction={openActionFor}
|
||||
onSwipeReply={(target) => setReplyingTo(target)}
|
||||
onScrollToReply={() => scrollToMessage(msg.replyTo!._id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actionMode ? (
|
||||
<ChatActionBar
|
||||
hideForward
|
||||
onReply={() => {
|
||||
if (selectedMessage) setReplyingTo(selectedMessage);
|
||||
closeActionMode();
|
||||
}}
|
||||
onCopy={copySelectedMessage}
|
||||
onCancel={closeActionMode}
|
||||
onReact={reactToMessage}
|
||||
/>
|
||||
) : (
|
||||
<MessageInput
|
||||
newMessage={newMessage}
|
||||
setNewMessage={setNewMessage}
|
||||
sendMessage={sendMessage}
|
||||
handleFileSelection={() => {}}
|
||||
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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddChatRoomMembersModal
|
||||
open={showAddMembers}
|
||||
onClose={() => setShowAddMembers(false)}
|
||||
roomId={roomId}
|
||||
existingMemberIds={room?.members?.map((member) => member._id) ?? []}
|
||||
onAdded={() => void loadRoom()}
|
||||
/>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
162
src/app/settings/chats/rooms/page.tsx
Normal file
162
src/app/settings/chats/rooms/page.tsx
Normal file
@@ -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<ChatRoomItem[]>([]);
|
||||
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 (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-7">
|
||||
<h1 className="text-2xl font-bold text-foreground">{t("chats.chatRoom")}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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")}
|
||||
>
|
||||
<BoldIcon name="add" size={22} tinted className="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 text-xs md:text-sm">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<IOSSpinner size={28} />
|
||||
</div>
|
||||
) : rooms.length === 0 ? (
|
||||
<p className="py-12 text-center text-neutral-500">
|
||||
{t("chats.roomsEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{rooms.map((room) => (
|
||||
<Link
|
||||
key={room._id}
|
||||
href={`/settings/chats/rooms/${room._id}`}
|
||||
className="flex items-center gap-3 py-3"
|
||||
>
|
||||
<ProfileAvatar
|
||||
src={room.image}
|
||||
alt={room.title}
|
||||
size="sm"
|
||||
rounded="full"
|
||||
className="h-12 w-12 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold">{room.title}</p>
|
||||
<p className="mt-0.5 truncate text-[11px] text-neutral-500">
|
||||
{room.visibility === "private"
|
||||
? t("chats.privateRoom")
|
||||
: [room.city?.name, room.province?.name, room.expertise]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || t("chats.forAllUsers")}
|
||||
</p>
|
||||
{room.lastMessage?.content ? (
|
||||
<p className="mt-1 truncate text-[11px] text-neutral-400">
|
||||
{room.lastMessage.content}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1 pl-1">
|
||||
<span className="rounded-full bg-[#22c55e]/15 px-2 py-0.5 text-[11px] font-semibold text-[#22c55e]">
|
||||
{t("chats.onlineCount", { count: room.onlineCount ?? 0 })}
|
||||
</span>
|
||||
<span className="text-[10px] text-neutral-400">
|
||||
{t("chats.memberCount", { count: room.memberCount ?? 0 })}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreateChatRoomModal
|
||||
open={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreated={() => void loadRooms()}
|
||||
/>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [isCheckedOne, setIsCheckedOne] = useState(false);
|
||||
const [isModalOpen, setModalOpen] = useState<boolean>(false);
|
||||
|
||||
const toggleCheckBox = () => setIsCheckedOne(!isCheckedOne);
|
||||
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
// انتخاب عکس از دوربین یا فایل
|
||||
const selectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center address-page">
|
||||
<span className="text-xl font-bold text-foreground">احراز هویت</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
برای ثبت درخواست، نیازمند احراز هویت شما هستیم
|
||||
</small>
|
||||
|
||||
<div className="relative w-[150px] h-[150px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-4">
|
||||
{nationalCartImg ? (
|
||||
<>
|
||||
<img
|
||||
src={
|
||||
nationalCartImg.startsWith("data:")
|
||||
? nationalCartImg
|
||||
: IMAGE_BASE_URL + nationalCartImg
|
||||
}
|
||||
alt="nationalCartImg"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={removeImage}
|
||||
className="p-4 absolute top-0 right-0"
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/close-circle.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="remove profile icon"
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="fileInput"
|
||||
className="cursor-pointer w-[150px] h-[150px] flex items-center justify-center text-white rounded-3xl border border-[#676767]"
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/gallery-add.svg"}
|
||||
width={76}
|
||||
height={76}
|
||||
alt="add profile icon"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment" // 🔥 مستقیم دوربین پشت
|
||||
className="hidden"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
اصل کارت ملی را بر روی سینه در دست گرفته و از خود عکس بگیرید
|
||||
</small>
|
||||
|
||||
<div className="flex items-center gap-2 mt-10">
|
||||
<input
|
||||
className="scale-125"
|
||||
type="checkbox"
|
||||
onChange={toggleCheckBox}
|
||||
/>
|
||||
<span
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="text-xs font-bold cursor-pointer"
|
||||
>
|
||||
تایید قوانین و مقررات
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center address-page">
|
||||
<span className="text-xl font-bold text-foreground">
|
||||
{t("settings.edit.nav.authentication")}
|
||||
</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
{t("settings.edit.authentication.intro")}
|
||||
</small>
|
||||
<div className="relative w-[150px] h-[150px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-4">
|
||||
{nationalCartImg ? (
|
||||
<>
|
||||
<img
|
||||
src={
|
||||
nationalCartImg.startsWith("data:")
|
||||
? nationalCartImg
|
||||
: IMAGE_BASE_URL + nationalCartImg
|
||||
}
|
||||
alt="nationalCartImg"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button onClick={() => setNationalCartImg(null)} className="p-4 absolute top-0 right-0">
|
||||
<Image src="/images/icons/close-circle.svg" width={25} height={25} alt="" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="fileInput"
|
||||
className="cursor-pointer w-[150px] h-[150px] flex items-center justify-center text-white rounded-3xl border border-[#676767]"
|
||||
>
|
||||
<Image src="/images/icons/gallery-add.svg" width={76} height={76} alt="" />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
id="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
{t("settings.edit.authentication.idHint")}
|
||||
</small>
|
||||
<div className="flex items-center gap-2 mt-10">
|
||||
<input className="scale-125" type="checkbox" onChange={() => setIsCheckedOne(!isCheckedOne)} />
|
||||
<span onClick={() => setModalOpen(true)} className="text-xs font-bold cursor-pointer">
|
||||
{t("settings.edit.authentication.acceptRules")}
|
||||
</span>
|
||||
</div>
|
||||
<AuthNextButton onClick={uploadImage} className="mt-20" loading={loading} disabled={loading}>
|
||||
{t("settings.edit.authentication.submitContinue")}
|
||||
</AuthNextButton>
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/settings/edit")}
|
||||
type="button"
|
||||
>
|
||||
{t("settings.edit.authentication.skip")}
|
||||
</button>
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
className="mt-20"
|
||||
loading={loading} disabled={loading}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/settings/edit")}
|
||||
type="button"
|
||||
>
|
||||
رد کن
|
||||
</button>
|
||||
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center justify-center address-page">
|
||||
<span className="text-xl mb-9 font-bold">بارگذاری مجوز</span>
|
||||
<span className="text-xl mb-9 font-bold">{t("settings.edit.license.title")}</span>
|
||||
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<ProfileAvatar
|
||||
@@ -197,7 +201,7 @@ function LicensePage() {
|
||||
</div>
|
||||
</div>
|
||||
<small className="font-bold mt-10 mb-4 text-center">
|
||||
برای ثبت درخواست نیازمند احراز هویت شما هستیم
|
||||
{t("settings.edit.license.intro")}
|
||||
</small>
|
||||
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
@@ -250,7 +254,7 @@ function LicensePage() {
|
||||
</div>
|
||||
{license && (
|
||||
<div className="flex flex-col items-center mt-4 text-green-700">
|
||||
<p>وضعیت تأیید: {license.Confirmation ? "تأیید شده" : "در انتظار تأیید"}</p>
|
||||
<p>{t("settings.edit.license.confirmStatus")}: {license.Confirmation ? t("settings.edit.license.confirmed") : t("settings.edit.license.pending")}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -258,7 +262,7 @@ function LicensePage() {
|
||||
</div>
|
||||
|
||||
<small className="font-bold mt-10 mb-4 text-center">
|
||||
برای دریافت تیک طلایی مجوز خود را بارگذاری کنید
|
||||
{t("settings.edit.license.uploadHint")}
|
||||
</small>
|
||||
|
||||
<div className="flex">
|
||||
@@ -283,16 +287,14 @@ function LicensePage() {
|
||||
</div>
|
||||
</div>
|
||||
<AuthNextButton onClick={upload} className="mt-20" loading={loadingUpload} disabled={loadingUpload}>
|
||||
ثبت و ادامه
|
||||
{t("settings.edit.license.submitContinue")}
|
||||
</AuthNextButton>
|
||||
<small className="font-bold mt-10 mb-1 text-center">
|
||||
زمان تایید مدارک 5 دقیقه تا 1 ساعت در ساعات اداری و
|
||||
</small>
|
||||
<small className="font-bold text-center">
|
||||
3 تا 8 ساعت در ساعات غیر اداری
|
||||
{t("settings.edit.license.reviewTime")}
|
||||
</small>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<div className="flex flex-col items-center mb-4">
|
||||
<span className="text-xl font-bold text-foreground">تصویر پروفایل</span>
|
||||
<div className="relative aspect-square w-[250px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-10">
|
||||
{avatar ? (
|
||||
<>
|
||||
<img
|
||||
src={
|
||||
avatar.startsWith("data:")
|
||||
? avatar
|
||||
: IMAGE_BASE_URL + avatar
|
||||
}
|
||||
alt="Avatar"
|
||||
className="w-full h-full aspect-square object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={removeImage}
|
||||
className="p-4 absolute top-0 right-0"
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/close-circle.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="remove profile icon"
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="fileInput"
|
||||
className="cursor-pointer w-[250px] h-[250px] flex items-center justify-center text-white rounded-3xl border border-[#676767] "
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/gallery-add.svg"}
|
||||
width={120}
|
||||
height={120}
|
||||
alt="add profile icon"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
id="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<div className="flex flex-col items-center mb-4">
|
||||
<span className="text-xl font-bold text-foreground">
|
||||
{t("settings.edit.nav.avatar")}
|
||||
</span>
|
||||
<div className="relative aspect-square w-[250px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-10">
|
||||
{avatar ? (
|
||||
<>
|
||||
<img
|
||||
src={avatar.startsWith("data:") ? avatar : IMAGE_BASE_URL + avatar}
|
||||
alt="Avatar"
|
||||
className="w-full h-full aspect-square object-cover"
|
||||
/>
|
||||
<button onClick={removeImage} className="p-4 absolute top-0 right-0">
|
||||
<Image src="/images/icons/close-circle.svg" width={25} height={25} alt="" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="fileInput"
|
||||
className="cursor-pointer w-[250px] h-[250px] flex items-center justify-center text-white rounded-3xl border border-[#676767]"
|
||||
>
|
||||
<Image src="/images/icons/gallery-add.svg" width={120} height={120} alt="" />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<AuthNextButton className="mt-2">
|
||||
<label
|
||||
className="cursor-pointer w-full h-full"
|
||||
htmlFor="fileInput"
|
||||
>
|
||||
ویرایش تصویر
|
||||
</label>
|
||||
</AuthNextButton>
|
||||
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
className="mt-20"
|
||||
loading={loadingUpload}
|
||||
disabled={loadingUpload || loading}
|
||||
>
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</Container>
|
||||
<input id="fileInput" type="file" accept="image/*" className="hidden" onChange={selectImage} />
|
||||
</div>
|
||||
<AuthNextButton className="mt-2">
|
||||
<label className="cursor-pointer w-full h-full" htmlFor="fileInput">
|
||||
{t("settings.edit.avatar.editImage")}
|
||||
</label>
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
className="mt-20"
|
||||
loading={loadingUpload}
|
||||
disabled={loadingUpload || loading}
|
||||
>
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold text-foreground">بیو</span>
|
||||
<div className="mt-5"></div>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<small className="font-bold mt-10"> در مورد خودتان چیزی بگویید</small>
|
||||
<textarea
|
||||
name="bio"
|
||||
placeholder="بیو"
|
||||
className={`border mt-4 w-full max-w-[290px] p-3 rounded-2xl border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
|
||||
formik.touched.bio && formik.errors.bio
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.bio}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.bio && formik.errors.bio && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.bio}
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold text-foreground">
|
||||
{t("settings.edit.nav.bio")}
|
||||
</span>
|
||||
<div className="mt-5"></div>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<small className="font-bold mt-10">
|
||||
{t("settings.edit.bio.aboutYou")}
|
||||
</small>
|
||||
)}
|
||||
<textarea
|
||||
name="bio"
|
||||
placeholder={t("settings.edit.bio.placeholder")}
|
||||
rows={5}
|
||||
className={`border mt-4 w-full max-w-[290px] p-3 rounded-2xl whitespace-pre-wrap border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
|
||||
formik.touched.bio && formik.errors.bio
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.bio}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.bio && formik.errors.bio && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.bio}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicRelations;
|
||||
export default BioPage;
|
||||
|
||||
@@ -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<number>(1); // Default: 1 for "قد"
|
||||
const [selectedHairImage, setSelectedHairImage] = useState<string>("");
|
||||
const [selectedEyeImage, setSelectedEyeImage] = useState<string>("");
|
||||
@@ -82,11 +88,11 @@ function Colors() {
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold text-foreground">سایز</span>
|
||||
|
||||
<p className="mt-8 text-center text-sm font-bold">مشخصات ظاهری</p>
|
||||
<span className="text-xl font-bold text-foreground">{t("settings.edit.nav.colors")}</span>
|
||||
<p className="mt-8 text-center text-sm font-bold">{t("settings.edit.sizes.appearance")}</p>
|
||||
|
||||
{/* Buttons for categories */}
|
||||
<div className="flex justify-center gap-2 mt-4 w-full max-w-[320px]">
|
||||
@@ -98,7 +104,7 @@ function Colors() {
|
||||
}`}
|
||||
onClick={() => handleButtonPress(1)}
|
||||
>
|
||||
رنگ چشم
|
||||
{t("settings.edit.colors.eyeColor")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
@@ -108,7 +114,7 @@ function Colors() {
|
||||
}`}
|
||||
onClick={() => handleButtonPress(2)}
|
||||
>
|
||||
رنگ مو
|
||||
{t("settings.edit.colors.hairColor")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -161,10 +167,11 @@ function Colors() {
|
||||
className="mt-10"
|
||||
loading={loading} disabled={loading}
|
||||
>
|
||||
ویرایش
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold text-foreground">نوع همکاری</span>
|
||||
|
||||
<p className="my-10 text-sm font-bold">
|
||||
آیا مایل به همکاری خارج از محل سکونت خود هستید؟
|
||||
</p>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<div className="flex w-full gap-2">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
بله خوشحال هم می شم
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
نه شهر خودم رو ترجیح میدم
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{formik.errors.selectedButton && formik.touched.selectedButton && (
|
||||
<div className="text-red-500 mt-2 text-sm">
|
||||
{formik.errors.selectedButton}
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold text-foreground">
|
||||
{t("settings.edit.nav.cooperationType")}
|
||||
</span>
|
||||
<p className="my-10 text-sm font-bold">
|
||||
{t("settings.edit.cooperationType.question")}
|
||||
</p>
|
||||
<form onSubmit={formik.handleSubmit} className="w-full max-w-sm flex flex-col items-center">
|
||||
<div className="flex w-full gap-2">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => 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")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => 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")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
{formik.errors.selectedButton && formik.touched.selectedButton && (
|
||||
<div className="text-red-500 mt-2 text-sm">{formik.errors.selectedButton}</div>
|
||||
)}
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>("");
|
||||
const [expertiseList, setExpertiseList] = useState<IExpertise[] | null>(null);
|
||||
const [subExpertise, setSubExpertise] = useState<string[]>([]);
|
||||
const [displaySubExpertise, setDisplaySubExpertise] = useState<string | null>(
|
||||
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 (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center min-h-screen justify-center">
|
||||
<span className="text-xl font-bold text-foreground">تخصص</span>
|
||||
<span className="text-xl font-bold text-foreground">
|
||||
{t("settings.edit.nav.expertise")}
|
||||
</span>
|
||||
|
||||
<p className="mt-8 text-center">در چه زمینه ای تخصص دارید؟</p>
|
||||
<p className="mt-8 text-center">{t("settings.edit.expertise.question")}</p>
|
||||
|
||||
<ExpertisePicker
|
||||
expertiseList={expertiseList}
|
||||
expertise={expertise}
|
||||
subExpertise={subExpertise}
|
||||
displaySubExpertise={displaySubExpertise}
|
||||
onExpertiseSelect={(value) => {
|
||||
setExpertise(value);
|
||||
setSubExpertise([]);
|
||||
setDisplaySubExpertise(null);
|
||||
}}
|
||||
onSubExpertiseToggle={(value) => {
|
||||
setSubExpertise((prev) =>
|
||||
prev.includes(value)
|
||||
? prev.filter((item) => item !== value)
|
||||
: [...prev, value]
|
||||
);
|
||||
}}
|
||||
onSubExpertiseToggle={handleSubExpertiseToggle}
|
||||
onDisplaySubExpertiseChange={setDisplaySubExpertise}
|
||||
/>
|
||||
|
||||
<AuthNextButton
|
||||
@@ -109,10 +178,11 @@ function Expertise() {
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
ویرایش
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
70
src/app/settings/edit/google-account/page.tsx
Normal file
70
src/app/settings/edit/google-account/page.tsx
Normal file
@@ -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<GoogleStatus | null>(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 (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.edit.nav.googleAccount")}</PageTitle>
|
||||
<div className="mx-auto flex max-w-md flex-col items-center py-8 text-center">
|
||||
{loading ? (
|
||||
<p className="text-sm text-neutral-500">{t("common.loading")}</p>
|
||||
) : status?.linked ? (
|
||||
<>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{t("settings.edit.googleAccount.linkedHint")}
|
||||
</p>
|
||||
{status.email ? (
|
||||
<p className="mt-3 text-sm font-semibold" dir="ltr">
|
||||
{status.email}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-6 text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{t("settings.edit.googleAccount.unlinkedHint")}
|
||||
</p>
|
||||
<GoogleSignInButton mode="link" onLinked={loadStatus} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
@@ -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<Metadata> {
|
||||
return generateSeoPageMetadata("settingsEdit", { index: false });
|
||||
}
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
|
||||
@@ -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 (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center justify-center address-page ">
|
||||
<span className="text-xl font-bold text-foreground">لوکیشن</span>
|
||||
<span className="text-xl font-bold text-foreground">{t("settings.edit.nav.location")}</span>
|
||||
<div className="mt-5"></div>
|
||||
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
مشخص کنید در کدام شهر قادر به انجام فعالیت هستید
|
||||
{t("settings.edit.location.question")}
|
||||
</small>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
@@ -168,7 +175,7 @@ function LocationPage() {
|
||||
onChange={handleProvinceChange}
|
||||
>
|
||||
<option disabled value="">
|
||||
استان
|
||||
{t("settings.profile.province")}
|
||||
</option>
|
||||
{allStates?.map((item: IProvince) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
@@ -192,7 +199,7 @@ function LocationPage() {
|
||||
onChange={(e) => formik.setFieldValue("cityId", e.target.value)}
|
||||
>
|
||||
<option disabled value="">
|
||||
شهر
|
||||
{t("settings.profile.city")}
|
||||
</option>
|
||||
{cities?.map((city: ICity) => (
|
||||
<option key={city.id} value={city.id}>
|
||||
@@ -208,7 +215,7 @@ function LocationPage() {
|
||||
|
||||
<textarea
|
||||
name="address"
|
||||
placeholder="آدرس"
|
||||
placeholder={t("settings.edit.location.addressPlaceholder")}
|
||||
className={`border mt-4 w-full max-w-[300px] p-3 rounded-2xl border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
|
||||
formik.touched.address && formik.errors.address
|
||||
? "border-red-500 dark:border-red-500"
|
||||
@@ -258,15 +265,16 @@ function LocationPage() {
|
||||
checked={isCheckedOne || false}
|
||||
/>
|
||||
<span className="text-xs font-bold">
|
||||
اطلاعات لوکیشن شما برای همه قابل نمایش باشد
|
||||
{t("settings.edit.location.showToAll")}
|
||||
</span>
|
||||
</label>
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
ویرایش
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"/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<string | undefined>("false");
|
||||
|
||||
@@ -18,32 +40,39 @@ function EditPage() {
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<PageTitle>ویرایش</PageTitle>
|
||||
<div className="px-4">
|
||||
<UserDetails />
|
||||
<div className="my-10 flex flex-col gap-5 text-sm font-semibold md:gap-8">
|
||||
{editUserNavLinks?.map((item) => (
|
||||
<Link
|
||||
className={`flex items-center gap-2 ${
|
||||
item?.title === "مجوز" && isRegister === "false" ? "hidden" : ""
|
||||
}`}
|
||||
key={item?.title}
|
||||
href={`/settings/edit${item?.href}`}
|
||||
>
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt={item?.title}
|
||||
src={`/images/icons/${item?.icon}`}
|
||||
className="dark:invert"
|
||||
/>
|
||||
<span>{item?.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.nav.edit")}</PageTitle>
|
||||
<div>
|
||||
<UserDetails />
|
||||
<div className="my-10 flex flex-col gap-5 text-sm font-semibold md:gap-8">
|
||||
{editUserNavLinks?.map((item) => (
|
||||
<Link
|
||||
className={`flex items-center gap-2 ${
|
||||
item.href === "/License" && isRegister === "false"
|
||||
? "hidden"
|
||||
: ""
|
||||
}`}
|
||||
key={item.href}
|
||||
href={`/settings/edit${item.href}`}
|
||||
>
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt={t(`settings.edit.nav.${EDIT_NAV_KEY[item.href]}`)}
|
||||
src={staticIconUrl(`/images/icons/${item.icon}`)}
|
||||
unoptimized
|
||||
className="shrink-0 dark:invert"
|
||||
/>
|
||||
<span className="whitespace-nowrap">
|
||||
{t(`settings.edit.nav.${EDIT_NAV_KEY[item.href]}`)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title={isGoogleUser ? "تنظیم کلمه عبور" : "تغییر کلمه عبور"} />
|
||||
{isGoogleUser && (
|
||||
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
|
||||
حساب شما با گوگل ساخته شده است. در صورت تمایل میتوانید کلمه عبور
|
||||
جداگانه تنظیم کنید.
|
||||
</p>
|
||||
)}
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
placeholder="کلمه عبور"
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.password && formik.errors.password
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.password}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead
|
||||
title={
|
||||
isGoogleUser
|
||||
? t("settings.edit.password.setTitle")
|
||||
: t("settings.edit.password.changeTitle")
|
||||
}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.password}
|
||||
</small>
|
||||
{isGoogleUser && (
|
||||
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
|
||||
{t("settings.edit.password.googleHint")}
|
||||
</p>
|
||||
)}
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
placeholder={t("settings.edit.password.placeholder")}
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.password && formik.errors.password
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.password}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.password}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthPasswordInput
|
||||
name="confirmPassword"
|
||||
placeholder="تکرار کلمه عبور"
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.confirmPassword}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.confirmPassword}
|
||||
</small>
|
||||
)}
|
||||
<AuthPasswordInput
|
||||
name="confirmPassword"
|
||||
placeholder={t("settings.edit.password.repeatPlaceholder")}
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.confirmPassword}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.confirmPassword}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
تایید کلمه عبور
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
{t("settings.edit.password.confirm")}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold text-foreground">روابط عمومی</span>
|
||||
<div className="mt-5"></div>
|
||||
|
||||
{/* <p className="my-10 text-sm font-bold">
|
||||
مایلید پروژه هایی گفتگو محور به شما پیشنهاد شود؟
|
||||
</p> */}
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
{/* <div className="flex w-full gap-2 max-w-[300px]">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
: ""
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold text-foreground">
|
||||
{t("settings.edit.nav.publicRelations")}
|
||||
</span>
|
||||
<form onSubmit={formik.handleSubmit} className="w-full max-w-sm flex flex-col items-center">
|
||||
<small className="font-bold mt-10">{t("settings.edit.bio.aboutYou")}</small>
|
||||
<textarea
|
||||
name="bio"
|
||||
placeholder={t("settings.edit.publicRelations.bioPlaceholder")}
|
||||
className={`border mt-4 w-full max-w-[290px] p-3 rounded-2xl border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
|
||||
formik.touched.bio && formik.errors.bio
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
روابط عمومی بالایی دارم
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
اهل معاشرت نیستم
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
{/* Error Message */}
|
||||
{/* {formik.errors.selectedButton && formik.touched.selectedButton && ( */}
|
||||
{/* <div className="text-red-500 mt-2 text-sm"> */}
|
||||
{/* {formik.errors.selectedButton} */}
|
||||
{/* </div> */}
|
||||
{/* )} */}
|
||||
<small className="font-bold mt-10"> در مورد خودتان چیزی بگویید</small>
|
||||
<textarea
|
||||
name="bio"
|
||||
placeholder="بیو"
|
||||
className={`border mt-4 w-full max-w-[290px] p-3 rounded-2xl border-border-primary-light dark:border-border-primary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${
|
||||
formik.touched.bio && formik.errors.bio
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.bio}
|
||||
onChange={(e) => {
|
||||
// محدود کردن به 500 کاراکتر
|
||||
if (e.target.value.length <= 500) {
|
||||
formik.setFieldValue("bio", e.target.value);
|
||||
}
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
|
||||
{/* شمارشگر کاراکتر */}
|
||||
<small className="mt-1 text-gray-500 block text-right">
|
||||
{formik.values.bio.length}/500
|
||||
</small>
|
||||
|
||||
{formik.touched.bio && formik.errors.bio && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.bio}
|
||||
value={formik.values.bio}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length <= 500) {
|
||||
formik.setFieldValue("bio", e.target.value);
|
||||
}
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
<small className="mt-1 text-gray-500 block text-right">
|
||||
{formik.values.bio.length}/500
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
{formik.touched.bio && formik.errors.bio && (
|
||||
<small className="text-red-500 mt-2 block">{formik.errors.bio}</small>
|
||||
)}
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Service[]>([]);
|
||||
@@ -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 (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold text-foreground">خدمات</span>
|
||||
|
||||
<div className="mt-5 w-full max-w-md">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<div className="gap-4 min-h-96">
|
||||
{services.map((service) => (
|
||||
<ServiceItem
|
||||
onDelete={() =>
|
||||
handleDeleteService(service?._id ? service?._id : service?.id)
|
||||
}
|
||||
key={service.id || service._id}
|
||||
service={service}
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold text-foreground">
|
||||
{t("settings.edit.nav.services")}
|
||||
</span>
|
||||
<div className="mt-5 w-full max-w-md">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<div className="gap-4 min-h-96">
|
||||
{services.map((service) => (
|
||||
<ServiceItem
|
||||
onDelete={() =>
|
||||
handleDeleteService(service?._id ? service?._id : service?.id)
|
||||
}
|
||||
key={service.id || service._id}
|
||||
service={service}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<AuthNextButton onClick={handleSubmit} loading={loading} disabled={loading} className="mb-4 w-32">
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="mb-4 !text-[#0066FF] !border-[#0066FF] w-32"
|
||||
>
|
||||
{t("settings.edit.add")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
{isModalOpen && (
|
||||
<AddServiceModal
|
||||
onClose={() => setModalOpen(false)}
|
||||
onAdd={handleAddService}
|
||||
isModalOpen={isModalOpen}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<AuthNextButton
|
||||
onClick={handleSubmit}
|
||||
loading={loading} disabled={loading}
|
||||
className="mb-4 w-32"
|
||||
>
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="mb-4 !text-[#0066FF] !border-[#0066FF] w-32"
|
||||
>
|
||||
افزودن
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
{isModalOpen && (
|
||||
<AddServiceModal
|
||||
onClose={() => setModalOpen(false)}
|
||||
onAdd={handleAddService}
|
||||
isModalOpen={isModalOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center auth-page">
|
||||
<span className="text-xl font-bold text-foreground">شماره شبا</span>
|
||||
<div className="mt-5"></div>
|
||||
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<small className="font-bold mt-10"> جهت واریز حق الزحمه</small>
|
||||
{/* شماره شبا */}
|
||||
<div className="flex flex-col w-full relative mt-4 ">
|
||||
<span className="absolute left-4 top-1.5">IR</span>
|
||||
<AuthInput
|
||||
name="shaba"
|
||||
type="text"
|
||||
placeholder="شماره شبا"
|
||||
className={`border w-full mx-auto h-[36px] p-2 max-w-full pl-8 text-sm ${
|
||||
formik.touched.shaba && formik.errors.shaba
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.shaba}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.shaba && formik.errors.shaba && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.shaba}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<small className="font-bold mt-10">
|
||||
شماره شبا باید به نام خود شخص باشد
|
||||
</small>
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center auth-page">
|
||||
<span className="text-xl font-bold text-foreground">
|
||||
{t("settings.edit.nav.shaba")}
|
||||
</span>
|
||||
<form onSubmit={formik.handleSubmit} className="w-full max-w-sm flex flex-col items-center">
|
||||
<small className="font-bold mt-10">{t("settings.edit.shaba.forPayout")}</small>
|
||||
<div className="flex flex-col w-full relative mt-4 ">
|
||||
<span className="absolute left-4 top-1.5">IR</span>
|
||||
<AuthInput
|
||||
name="shaba"
|
||||
type="text"
|
||||
placeholder={t("settings.edit.shaba.placeholder")}
|
||||
className={`border w-full mx-auto h-[36px] p-2 max-w-full pl-8 text-sm ${
|
||||
formik.touched.shaba && formik.errors.shaba
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.shaba}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.shaba && formik.errors.shaba && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.shaba}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<small className="font-bold mt-10">{t("settings.edit.shaba.mustBeOwn")}</small>
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthPage;
|
||||
export default ShabaPage;
|
||||
|
||||
@@ -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<string>("");
|
||||
const [weight, setWeight] = useState<string>("");
|
||||
const [size, setSize] = useState<string | null>(null);
|
||||
@@ -32,7 +38,7 @@ function Sizes() {
|
||||
setSize(user?.size || "");
|
||||
}, [user]);
|
||||
|
||||
const [selectedButton, setSelectedButton] = useState<number>(1); // Default: 1 for "قد"
|
||||
const [selectedButton, setSelectedButton] = useState<number>(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 (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold text-foreground">سایز</span>
|
||||
<p className="mt-8 text-center text-sm font-bold mb-4">مشخصات ظاهری</p>
|
||||
{/* Buttons for categories */}
|
||||
<div className="flex gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(1)}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/ruler&pen.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
className="dark:invert"
|
||||
alt="قد"
|
||||
/>
|
||||
قد
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(2)}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/weight.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
className="dark:invert"
|
||||
alt="وزن"
|
||||
/>
|
||||
وزن
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 3
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(3)}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/timer.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
className="dark:invert"
|
||||
alt="سایز"
|
||||
/>
|
||||
سایز
|
||||
</button>
|
||||
</div>
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold text-foreground">
|
||||
{t("settings.edit.nav.sizes")}
|
||||
</span>
|
||||
<p className="mt-8 text-center text-sm font-bold mb-4">
|
||||
{t("settings.edit.sizes.appearance")}
|
||||
</p>
|
||||
<div className="flex gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
|
||||
}`}
|
||||
onClick={() => setSelectedButton(1)}
|
||||
>
|
||||
<Image src="/images/icons/ruler&pen.svg" width={25} height={25} className="dark:invert" alt="" />
|
||||
{t("settings.edit.sizes.height")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => setSelectedButton(2)}
|
||||
>
|
||||
<Image src="/images/icons/weight.svg" width={25} height={25} className="dark:invert" alt="" />
|
||||
{t("settings.edit.sizes.weight")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 3
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => setSelectedButton(3)}
|
||||
>
|
||||
<Image src="/images/icons/timer.svg" width={25} height={25} className="dark:invert" alt="" />
|
||||
{t("settings.edit.sizes.size")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inputs based on selected button */}
|
||||
<div className="mt-6 w-full max-w-md flex flex-col items-center">
|
||||
{selectedButton === 1 && (
|
||||
<input
|
||||
type="number"
|
||||
placeholder="قد"
|
||||
className="w-20 mx-auto p-2 mb-4 border rounded-full text-center bg-tertiary-light dark:bg-tertiary-dark"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{selectedButton === 2 && (
|
||||
<input
|
||||
type="number"
|
||||
placeholder="وزن"
|
||||
className="w-20 mx-auto p-2 mb-4 border rounded-full text-center bg-tertiary-light dark:bg-tertiary-dark"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{selectedButton === 3 && (
|
||||
<div className="grid grid-cols-3 gap-2 w-full max-w-[320px]">
|
||||
{sizes.map((sizeOption) => (
|
||||
<button
|
||||
key={sizeOption}
|
||||
className={`p-2 py-1 rounded-full border text-sm full ${
|
||||
size === sizeOption
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleSizeSelection(sizeOption)}
|
||||
>
|
||||
{sizeOption}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 w-full max-w-md flex flex-col items-center">
|
||||
{selectedButton === 1 && (
|
||||
<input
|
||||
type="number"
|
||||
placeholder={t("settings.edit.sizes.height")}
|
||||
className="w-20 mx-auto p-2 mb-4 border rounded-full text-center bg-tertiary-light dark:bg-tertiary-dark"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{selectedButton === 2 && (
|
||||
<input
|
||||
type="number"
|
||||
placeholder={t("settings.edit.sizes.weight")}
|
||||
className="w-20 mx-auto p-2 mb-4 border rounded-full text-center bg-tertiary-light dark:bg-tertiary-dark"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{selectedButton === 3 && (
|
||||
<div className="grid grid-cols-3 gap-2 w-full max-w-[320px]">
|
||||
{sizes.map((sizeOption) => (
|
||||
<button
|
||||
key={sizeOption}
|
||||
className={`p-2 py-1 rounded-full border text-sm full ${
|
||||
size === sizeOption
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => setSize(sizeOption)}
|
||||
>
|
||||
{sizeOption}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
className="mt-10"
|
||||
loading={loading} disabled={loading}
|
||||
>
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</Container>
|
||||
<AuthNextButton type="button" onClick={handleCheck} className="mt-10" loading={loading} disabled={loading}>
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
212
src/app/settings/edit/two-factor/page.tsx
Normal file
212
src/app/settings/edit/two-factor/page.tsx
Normal file
@@ -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<TotpStatus | null>(null);
|
||||
const [setup, setSetup] = useState<TotpSetup | null>(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 (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.edit.nav.twoFactor")}</PageTitle>
|
||||
<div className="mx-auto flex max-w-md flex-col items-center py-8">
|
||||
{pageLoading ? (
|
||||
<p className="text-sm text-neutral-500">{t("common.loading")}</p>
|
||||
) : status?.enabled ? (
|
||||
<>
|
||||
<p className="mb-6 text-center text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{t("settings.edit.twoFactor.enabledHint")}
|
||||
</p>
|
||||
<AuthOtpInput value={code} onChange={setCode} className="mt-2" />
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
className="mt-6"
|
||||
loading={loading}
|
||||
disabled={loading || code.length !== 6}
|
||||
onClick={disableTotp}
|
||||
>
|
||||
{t("settings.edit.twoFactor.disableButton")}
|
||||
</AuthNextButton>
|
||||
</>
|
||||
) : setup ? (
|
||||
<>
|
||||
<p className="mb-4 text-center text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{t("settings.edit.twoFactor.scanHint")}
|
||||
</p>
|
||||
{qrCodeUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt={t("settings.edit.nav.twoFactor")}
|
||||
width={220}
|
||||
height={220}
|
||||
className="mb-4 rounded-lg bg-white p-2"
|
||||
/>
|
||||
) : null}
|
||||
<p
|
||||
className="mb-2 text-center text-xs text-neutral-500"
|
||||
dir="ltr"
|
||||
>
|
||||
{setup.secret}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copySecret}
|
||||
className="mb-4 text-xs text-[#0033EA] underline"
|
||||
>
|
||||
{t("settings.edit.twoFactor.copySecret")}
|
||||
</button>
|
||||
<AuthOtpInput value={code} onChange={setCode} />
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
className="mt-6"
|
||||
loading={loading}
|
||||
disabled={loading || code.length !== 6}
|
||||
onClick={enableTotp}
|
||||
>
|
||||
{t("settings.edit.twoFactor.enableButton")}
|
||||
</AuthNextButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelSetup}
|
||||
className="mt-4 text-sm text-neutral-500 underline"
|
||||
>
|
||||
{t("settings.edit.twoFactor.cancelSetup")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-6 text-center text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{t("settings.edit.twoFactor.disabledHint")}
|
||||
</p>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={startSetup}
|
||||
>
|
||||
{t("settings.edit.twoFactor.startSetup")}
|
||||
</AuthNextButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="نام کاربری" />
|
||||
<AuthHead title={t("settings.edit.nav.username")} />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
@@ -72,7 +76,7 @@ function UsernamePage() {
|
||||
<AuthInput
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="نام کاربری"
|
||||
placeholder={t("settings.edit.username.placeholder")}
|
||||
dir="ltr"
|
||||
autoComplete="username"
|
||||
spellCheck={false}
|
||||
@@ -88,7 +92,7 @@ function UsernamePage() {
|
||||
|
||||
{isDuplicate && (
|
||||
<small className="text-red-500 mt-2 block text-center font-medium">
|
||||
نام کاربری تکراری است
|
||||
{t("settings.edit.username.duplicate")}
|
||||
</small>
|
||||
)}
|
||||
|
||||
@@ -108,11 +112,12 @@ function UsernamePage() {
|
||||
className="mt-20"
|
||||
loading={loading} disabled={loading || formik.values.username.length < USERNAME_MIN_LENGTH}
|
||||
>
|
||||
ویرایش
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
22
src/app/settings/favorites/FavoritesPageClient.tsx
Normal file
22
src/app/settings/favorites/FavoritesPageClient.tsx
Normal file
@@ -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 (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.nav.favorites")}</PageTitle>
|
||||
<UserDetails />
|
||||
<FavoritesGrid />
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user