Initial commit - modstagram-next
This commit is contained in:
53
src/api/fetchBillboards.ts
Normal file
53
src/api/fetchBillboards.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
|
||||
export async function fetchBillboards(
|
||||
page: number,
|
||||
limit: number,
|
||||
filters: {
|
||||
province?: string;
|
||||
city?: string;
|
||||
category?: string;
|
||||
sort?: string;
|
||||
search?: string;
|
||||
},
|
||||
token: string
|
||||
) {
|
||||
const validFilters = Object.entries(filters || {})
|
||||
.filter(([_, value]) => value !== undefined && value !== "")
|
||||
.reduce((acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
...validFilters,
|
||||
timestamp: Date.now().toString(), // اضافه کردن timestamp برای جلوگیری از کش
|
||||
}).toString();
|
||||
|
||||
const apiUrl = `${BASE_URL}/advertising/web?${queryParams}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 90_000);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(apiUrl, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
49
src/api/fetchPostById.ts
Normal file
49
src/api/fetchPostById.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { Post } from "@/types/types";
|
||||
|
||||
/** Fetch a single post by id (tries dedicated endpoint, then list fallback) */
|
||||
export async function fetchPostById(
|
||||
id: string,
|
||||
token?: string
|
||||
): Promise<Post | null> {
|
||||
const headers: HeadersInit = token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {};
|
||||
|
||||
const tryUrls = [
|
||||
`${BASE_URL}/posts/${id}`,
|
||||
`${BASE_URL}/posts/web/${id}`,
|
||||
`${BASE_URL}/posts/get/${id}`,
|
||||
];
|
||||
|
||||
for (const url of tryUrls) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) continue;
|
||||
const data = await res.json();
|
||||
const post = data?.post ?? data?.data ?? data;
|
||||
if (post?._id) return post as Post;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BASE_URL}/users/web?page=1&limit=50&_id=${id}`,
|
||||
{ cache: "no-store", headers }
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const found = (data?.posts as Post[])?.find((p) => p._id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
44
src/api/fetchPosts.ts
Normal file
44
src/api/fetchPosts.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
|
||||
export async function fetchPosts(
|
||||
page: number,
|
||||
limit: number,
|
||||
filters: {
|
||||
expertise?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
userLevel?: string;
|
||||
rateFilter?: string;
|
||||
_id?: string;
|
||||
type?: string;
|
||||
},
|
||||
token: string
|
||||
) {
|
||||
const validFilters = Object.entries(filters || {})
|
||||
.filter(([value]) => value !== undefined && value !== "")
|
||||
.reduce((acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
...validFilters,
|
||||
}).toString();
|
||||
|
||||
const apiUrl = `${BASE_URL}/users/web?${queryParams}`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
45
src/api/fetchProjects.ts
Normal file
45
src/api/fetchProjects.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
|
||||
export async function fetchProjects(
|
||||
page: number,
|
||||
limit: number,
|
||||
filters: {
|
||||
expertise?: string;
|
||||
most_requests?: string;
|
||||
most_price?: string;
|
||||
age?: string;
|
||||
gender?: string;
|
||||
},
|
||||
token: string
|
||||
) {
|
||||
// فیلتر کردن مقادیر نامعتبر
|
||||
const validFilters = Object.entries(filters || {})
|
||||
.filter(([_, value]) => value !== undefined && value !== "")
|
||||
.reduce((acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
...validFilters,
|
||||
timestamp: Date.now().toString(), // اضافه کردن timestamp برای جلوگیری از کش
|
||||
}).toString();
|
||||
|
||||
const apiUrl = `${BASE_URL}/projects?${queryParams}`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
cache: "no-store", // غیرفعال کردن کش
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
42
src/api/fetchSearch.ts
Normal file
42
src/api/fetchSearch.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
|
||||
export async function fetchSearch(
|
||||
page: number,
|
||||
limit: number,
|
||||
filters: {
|
||||
type?: string;
|
||||
search?: string;
|
||||
},
|
||||
token: string
|
||||
) {
|
||||
// فیلتر کردن مقادیر نامعتبر
|
||||
const validFilters = Object.entries(filters || {})
|
||||
.filter(([_, value]) => value !== undefined && value !== '')
|
||||
.reduce((acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
...validFilters,
|
||||
timestamp: Date.now().toString(), // اضافه کردن timestamp برای جلوگیری از کش
|
||||
}).toString();
|
||||
|
||||
const apiUrl = `${BASE_URL}/search-web?${queryParams}`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
cache: 'no-store', // غیرفعال کردن کش
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
128
src/app/(auth)/(login)/change-passowrd/page.tsx
Normal file
128
src/app/(auth)/(login)/change-passowrd/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import AuthButton from "@/components/auth/AuthButton";
|
||||
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 Link from "next/link";
|
||||
import React, { 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";
|
||||
|
||||
// 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("تکرار کلمه عبور الزامی است"),
|
||||
});
|
||||
|
||||
function ChangePassword() {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
const otp =
|
||||
typeof window !== "undefined" ? localStorage.getItem("otp") : null;
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
(await request("POST", "/login/change_password", {
|
||||
mobile,
|
||||
otp,
|
||||
new_password: values.password,
|
||||
})) as IVerifyOtp;
|
||||
router.push("/login");
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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="password"
|
||||
type="password"
|
||||
placeholder="کلمه عبور جدید"
|
||||
className={`border mt-4 ${
|
||||
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>
|
||||
)}
|
||||
|
||||
<AuthInput
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
placeholder="تکرار کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
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" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "تغییر کلمه عبور"}
|
||||
</AuthButton>
|
||||
</form>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangePassword;
|
||||
121
src/app/(auth)/(login)/forget-password-otp/page.tsx
Normal file
121
src/app/(auth)/(login)/forget-password-otp/page.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import AuthButton from "@/components/auth/AuthButton";
|
||||
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 OtpInput from "react-otp-input";
|
||||
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 Cookies from "js-cookie";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, "کد وارد شده صحیح نیست")
|
||||
.required("کد تایید را وارد کنید"),
|
||||
});
|
||||
|
||||
function ForgetPasswordOtp() {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
otp: "",
|
||||
mobile: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values: { mobile: string; otp: string }) => {
|
||||
const mobile = localStorage.getItem("mobile");
|
||||
if (!mobile) {
|
||||
console.log("Mobile number is missing in localStorage!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = (await request("POST", "/login/verify", {
|
||||
mobile,
|
||||
otp: values.otp.trim(),
|
||||
})) as IVerifyOtp;
|
||||
document.cookie = `token=${response.token}; path=/; Secure`;
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365
|
||||
});
|
||||
await localStorage.setItem("otp", values.otp);
|
||||
router.push("/change-passowrd");
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="فراموش کردن کلمه عبور" />
|
||||
<small className="mb-10">کد 6 رقمی به شماره {mobile} ارسال شد</small>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<OtpInput
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
if (formik.values.otp !== otp) {
|
||||
formik.setFieldValue("otp", otp);
|
||||
if (otp.length === 6) {
|
||||
setTimeout(() => {
|
||||
formik.submitForm();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}}
|
||||
numInputs={6}
|
||||
containerStyle="mt-3 dir-ltr flex gap-x-4 justify-center"
|
||||
inputStyle={`text-center min-w-[30px] border bg-secondary-light dark:bg-secondary-dark rounded-2xl py-3 ${
|
||||
formik.touched.otp && formik.errors.otp
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-border-primary-light dark:border-border-primary-dark"
|
||||
} focus:outline-none`}
|
||||
renderInput={(props) => <input {...props} />}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
disabled={loading || formik.values.otp.length !== 6}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "تایید کد"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default ForgetPasswordOtp;
|
||||
83
src/app/(auth)/(login)/forget-password/page.tsx
Normal file
83
src/app/(auth)/(login)/forget-password/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import AuthButton from "@/components/auth/AuthButton";
|
||||
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 from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
mobile: yup
|
||||
.string()
|
||||
.matches(/^(09\d{9})$/, "شماره موبایل معتبر نیست")
|
||||
.required("شماره موبایل الزامی است"),
|
||||
});
|
||||
|
||||
function ForgetPassword() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
mobile: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
await request("POST", "/login", { mobile: values.mobile });
|
||||
await localStorage.setItem("mobile", values.mobile);
|
||||
router.push("/forget-password-otp");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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}
|
||||
type="text"
|
||||
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" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ارسال کد"}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/register"}>
|
||||
<small className="text-[#292D32] font-bold text-xs mt-8 block">
|
||||
حساب کاربری ندارید؟ <span className="text-[#0033EA]">ثبت نام</span>
|
||||
</small>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default ForgetPassword;
|
||||
149
src/app/(auth)/(login)/login-with-username/page.tsx
Normal file
149
src/app/(auth)/(login)/login-with-username/page.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import AuthButton from "@/components/auth/AuthButton";
|
||||
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 Link from "next/link";
|
||||
import React, { 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 Cookies from "js-cookie";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
password: yup
|
||||
.string()
|
||||
.required("کلمه عبور نمی\u200Cتواند خالی باشد")
|
||||
.min(8, "کلمه عبور نباید کوتاه تر از 8 کاراکتر باشد")
|
||||
.matches(
|
||||
/^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/,
|
||||
"کلمه عبور باید شامل حروف و اعداد باشد"
|
||||
),
|
||||
username: yup.string().required("نام کاربری الزامی است"),
|
||||
});
|
||||
|
||||
function LoginWithUsername() {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
const response = (await request("POST", "/login/login_username", {
|
||||
mobile,
|
||||
user_name: values.username,
|
||||
password: values.password,
|
||||
})) as IVerifyOtp;
|
||||
switch (response?.page) {
|
||||
case "home":
|
||||
document.cookie = `token=${response.token}; path=/; Secure`;
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365,
|
||||
});
|
||||
|
||||
await localStorage.setItem("usertype", response.user_type);
|
||||
await localStorage.setItem("id", response.id);
|
||||
await localStorage.setItem("step", response.step);
|
||||
router.push("/");
|
||||
break;
|
||||
case "auth-page":
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365,
|
||||
});
|
||||
// await localStorage.setItem("usertype", response.user_type);
|
||||
await localStorage.setItem("id", response.id);
|
||||
await localStorage.setItem("step", response.step);
|
||||
router.push("/auth");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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="نام کاربری"
|
||||
className={`border ${
|
||||
formik.touched.username && formik.errors.username
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.username}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.username && formik.errors.username && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.username}
|
||||
</small>
|
||||
)}
|
||||
<AuthInput
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
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>
|
||||
)}
|
||||
<AuthButton type="submit" className="mt-5" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ورود"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginWithUsername;
|
||||
92
src/app/(auth)/(login)/login/page.tsx
Normal file
92
src/app/(auth)/(login)/login/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import AuthButton from "@/components/auth/AuthButton";
|
||||
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 Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
mobile: yup
|
||||
.string()
|
||||
.matches(/^(09\d{9})$/, "شماره موبایل معتبر نیست")
|
||||
.required("شماره موبایل الزامی است"),
|
||||
});
|
||||
|
||||
function Login() {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
mobile: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
await request("POST", "/login", { mobile: values.mobile });
|
||||
await localStorage.setItem("mobile", values.mobile);
|
||||
router.push("/verify-otp");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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}
|
||||
type="tel"
|
||||
pattern="[0-9]*"
|
||||
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" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ارسال کد"}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/login-with-username"}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
162
src/app/(auth)/(login)/verify-otp/page.tsx
Normal file
162
src/app/(auth)/(login)/verify-otp/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import AuthButton from "@/components/auth/AuthButton";
|
||||
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 OtpInput from "react-otp-input";
|
||||
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 Cookies from "js-cookie";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, "کد وارد شده صحیح نیست")
|
||||
.required("کد تایید را وارد کنید"),
|
||||
});
|
||||
|
||||
function VerifyOtp() {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
otp: "",
|
||||
mobile: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values: { mobile: string; otp: string }) => {
|
||||
const mobile = localStorage.getItem("mobile");
|
||||
if (!mobile) {
|
||||
console.log("Mobile number is missing in localStorage!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = (await request("POST", "/login/verify", {
|
||||
mobile,
|
||||
otp: values.otp.trim(),
|
||||
})) as IVerifyOtp;
|
||||
|
||||
switch (response?.page) {
|
||||
case "home":
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365,
|
||||
});
|
||||
await localStorage.setItem("usertype", response.user_type);
|
||||
await localStorage.setItem("id", response.id);
|
||||
await localStorage.setItem("step", response.step);
|
||||
router.push("/");
|
||||
break;
|
||||
case "auth-page":
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365,
|
||||
});
|
||||
// await localStorage.setItem("usertype", response.user_type);
|
||||
await localStorage.setItem("id", response.id);
|
||||
await localStorage.setItem("step", response.step);
|
||||
|
||||
switch (response.step) {
|
||||
case "user_name":
|
||||
router.push("/register/username");
|
||||
break;
|
||||
case "password":
|
||||
router.push("/register/password");
|
||||
break;
|
||||
case "first_name":
|
||||
router.push("/register/fullname");
|
||||
break;
|
||||
case "user_type":
|
||||
router.push("/register/usertype");
|
||||
break;
|
||||
default:
|
||||
router.push("/verify/avatar");
|
||||
break;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// router.push("/dashboard");
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="ورود" />
|
||||
<small className="mb-10">کد 6 رقمی به شماره {mobile} ارسال شد</small>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<OtpInput
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
if (otp.length === 6) {
|
||||
setTimeout(() => formik.submitForm(), 500);
|
||||
}
|
||||
}}
|
||||
numInputs={6}
|
||||
containerStyle="mt-3 dir-ltr flex gap-x-4 justify-center"
|
||||
inputStyle={`text-center min-w-[30px] border bg-secondary-light dark:bg-secondary-dark rounded-2xl py-3 ${
|
||||
formik.touched.otp && formik.errors.otp
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-border-primary-light "
|
||||
} focus:outline-none`}
|
||||
renderInput={(props) => (
|
||||
<input
|
||||
{...props}
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
disabled={loading || formik.values.otp.length !== 6}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "تایید کد"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default VerifyOtp;
|
||||
125
src/app/(auth)/(register)/register-otp/page.tsx
Normal file
125
src/app/(auth)/(register)/register-otp/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import AuthButton from "@/components/auth/AuthButton";
|
||||
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 OtpInput from "react-otp-input";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
otp: yup
|
||||
.string()
|
||||
.matches(/^\d{6}$/, "کد وارد شده صحیح نیست")
|
||||
.required("کد تایید را وارد کنید"),
|
||||
});
|
||||
|
||||
function RegisterOtp() {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
otp: "",
|
||||
mobile: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values: { mobile: string; otp: string }) => {
|
||||
const mobile = localStorage.getItem("mobile");
|
||||
if (!mobile) {
|
||||
console.log("Mobile number is missing in localStorage!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = (await request("POST", "/register/verify", {
|
||||
mobile,
|
||||
otp: values.otp.trim(),
|
||||
})) as IVerifyOtp;
|
||||
await localStorage.setItem("otp", values.otp);
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365
|
||||
});
|
||||
await localStorage.setItem("step", response.step);
|
||||
response.step === "user_name"
|
||||
? router.push("/register/username")
|
||||
: response.step === "password"
|
||||
? router.push("/register/password")
|
||||
: response.step === "first_name"
|
||||
? router.push("/register/fullname")
|
||||
: response.step === "user_type"
|
||||
? router.push("/register/usertype")
|
||||
: router.push("/verify/avatar");
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="ثبت نام" />
|
||||
<small className="mb-10">کد 6 رقمی به شماره {mobile} ارسال شد</small>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<OtpInput
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
if (formik.values.otp !== otp) {
|
||||
formik.setFieldValue("otp", otp);
|
||||
if (otp.length === 6) {
|
||||
setTimeout(() => {
|
||||
formik.submitForm();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}}
|
||||
numInputs={6}
|
||||
containerStyle="mt-3 dir-ltr flex gap-x-4 justify-center"
|
||||
inputStyle={`text-center min-w-[30px] border bg-secondary-light dark:bg-secondary-dark rounded-2xl py-3 ${
|
||||
formik.touched.otp && formik.errors.otp
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-border-primary-light dark:border-border-primary-dark"
|
||||
} focus:outline-none`}
|
||||
renderInput={(props) => <input {...props} />}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
disabled={loading || formik.values.otp.length !== 6}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "تایید کد"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default RegisterOtp;
|
||||
102
src/app/(auth)/(register)/register/fullname/page.tsx
Normal file
102
src/app/(auth)/(register)/register/fullname/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import React 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";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
family: yup.string().required("نام خانوادگی الزامی است"),
|
||||
name: yup.string().required("نام الزامی است"),
|
||||
});
|
||||
|
||||
function FullNamePage() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
family: "",
|
||||
name: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
(await request("POST", "/register/fullname", {
|
||||
mobile: mobile,
|
||||
first_name: values.name,
|
||||
last_name: values.family,
|
||||
})) as IVerifyOtp;
|
||||
router.push("/register/usertype");
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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="name"
|
||||
type="text"
|
||||
placeholder="نام"
|
||||
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">
|
||||
{formik.errors.name}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthInput
|
||||
name="family"
|
||||
type="text"
|
||||
placeholder="نام خانوادگی"
|
||||
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">
|
||||
{formik.errors.family}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default FullNamePage;
|
||||
85
src/app/(auth)/(register)/register/page.tsx
Normal file
85
src/app/(auth)/(register)/register/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import AuthButton from "@/components/auth/AuthButton";
|
||||
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 useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
mobile: yup
|
||||
.string()
|
||||
.matches(/^(09\d{9})$/, "شماره موبایل معتبر نیست")
|
||||
.required("شماره موبایل الزامی است"),
|
||||
});
|
||||
|
||||
function Register() {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
mobile: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
await request("POST", "/register", { mobile: values.mobile });
|
||||
await localStorage.setItem("mobile", values.mobile);
|
||||
router.push("/register-otp");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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}
|
||||
type="text"
|
||||
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" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ارسال کد"}
|
||||
</AuthButton>
|
||||
</form>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default Register;
|
||||
118
src/app/(auth)/(register)/register/password/page.tsx
Normal file
118
src/app/(auth)/(register)/register/password/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import React 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";
|
||||
|
||||
// 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("تکرار کلمه عبور الزامی است"),
|
||||
});
|
||||
|
||||
function PasswordPage() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
const otp =
|
||||
typeof window !== "undefined" ? localStorage.getItem("otp") : null;
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
(await request("POST", "/login/change_password", {
|
||||
mobile,
|
||||
otp,
|
||||
new_password: values.password,
|
||||
})) as IVerifyOtp;
|
||||
router.push("/register/fullname");
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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="password"
|
||||
type="password"
|
||||
placeholder="کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
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>
|
||||
)}
|
||||
|
||||
<AuthInput
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
placeholder="تکرار کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
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" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "تایید کلمه عبور"}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordPage;
|
||||
92
src/app/(auth)/(register)/register/username/page.tsx
Normal file
92
src/app/(auth)/(register)/register/username/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import React 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 toast from "react-hot-toast";
|
||||
|
||||
// ✅ فقط چک میکنیم مقدار وارد شده باشه
|
||||
const schema = yup.object().shape({
|
||||
username: yup
|
||||
.string()
|
||||
.required("نام کاربری الزامی است")
|
||||
.min(1, "نام کاربری باید حداقل ۱ کاراکتر باشد")
|
||||
.max(100, "نام کاربری نمیتواند بیشتر از ۱۰۰ کاراکتر باشد"),
|
||||
});
|
||||
|
||||
function UsernamePage() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
username: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
await request("POST", "/register/username", {
|
||||
mobile,
|
||||
user_name: values.username,
|
||||
}) as IVerifyOtp;
|
||||
|
||||
localStorage.setItem("username", values.username);
|
||||
router.push("/register/password");
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err?.message);
|
||||
if (err?.response?.data?.message) {
|
||||
toast.error(err.response.data.message);
|
||||
} else {
|
||||
toast.error("خطایی رخ داد، دوباره تلاش کنید");
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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="نام کاربری خود را وارد کنید"
|
||||
dir="auto" // ✅ اجازه ورود همه کاراکترها (فارسی، انگلیسی، ایموجی...)
|
||||
className={`border ${
|
||||
formik.touched.username && formik.errors.username
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.username}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.username && formik.errors.username && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.username}
|
||||
</small>
|
||||
)}
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default UsernamePage;
|
||||
125
src/app/(auth)/(register)/register/usertype/page.tsx
Normal file
125
src/app/(auth)/(register)/register/usertype/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
selectedButton: yup.string(),
|
||||
});
|
||||
|
||||
function UsertypePage() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
// State for managing submit action
|
||||
const [submitAction, setSubmitAction] = useState<"register" | "login" | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedButton: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
await request("POST", "/register/user_type", {
|
||||
mobile: mobile,
|
||||
// user_type: values.selectedButton,
|
||||
user_type: "user",
|
||||
});
|
||||
|
||||
if (submitAction === "register") {
|
||||
// localStorage.setItem("usertype", values.selectedButton);
|
||||
localStorage.setItem("usertype", "user");
|
||||
router.push("/verify/avatar");
|
||||
} else if (submitAction === "login") {
|
||||
// localStorage.setItem("usertype", values.selectedButton);
|
||||
localStorage.setItem("usertype", "user");
|
||||
router.push("/");
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "خطای نامشخصی رخ داد.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
{/* <AuthHead title="نوع کاربری" /> */}
|
||||
<AuthHead title="" />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
{/* User Type Buttons */}
|
||||
{/* <AuthNextButton
|
||||
type="button"
|
||||
onClick={() => formik.setFieldValue("selectedButton", "user")}
|
||||
className={`mt-5 w-full py-3 max-w-[250px] ${
|
||||
formik.values.selectedButton === "user"
|
||||
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
کاربر
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => formik.setFieldValue("selectedButton", "employer")}
|
||||
className={`mt-5 w-full py-3 max-w-[250px] ${
|
||||
formik.values.selectedButton === "employer"
|
||||
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
کارفرما
|
||||
</AuthNextButton> */}
|
||||
|
||||
{/* Error Message */}
|
||||
{formik.errors.selectedButton && formik.touched.selectedButton && (
|
||||
<div className="text-red-500 mt-2 text-sm">
|
||||
{formik.errors.selectedButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
onClick={() => setSubmitAction("register")}
|
||||
className="mt-20 !border-[#0C8002] !text-[#0C8002]"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ادامه ثبت نام"}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
onClick={() => setSubmitAction("login")}
|
||||
className="mt-20 !border-[#FF0000] !text-[#FF0000]"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ورود به برنامه"}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default UsertypePage;
|
||||
184
src/app/(auth)/verify/auth/page.tsx
Normal file
184
src/app/(auth)/verify/auth/page.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
import AuthUserDetails from "@/components/auth/AuthUserDetails";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import DatePicker from "react-multi-date-picker";
|
||||
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("کد ملی الزامی است"),
|
||||
});
|
||||
|
||||
function AuthPage() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const p2e = (es: string): string =>
|
||||
es.replace(/[۰-۹]/g, (d) => String.fromCharCode("۰۱۲۳۴۵۶۷۸۹".indexOf(d) + 48));
|
||||
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
nationalCode: "",
|
||||
birthDate: "",
|
||||
shaba: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
await request("POST", "/verify/auth", {
|
||||
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 || "خطای نامشخصی رخ داد.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log(formik.values);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center auth-page">
|
||||
<span className="text-xl font-bold">احراز هویت</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
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}
|
||||
/>
|
||||
</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}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<small className="font-bold mt-10">
|
||||
شماره شبا باید به نام خود شخص باشد
|
||||
</small>
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
</AuthNextButton>
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/location")}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthPage;
|
||||
209
src/app/(auth)/verify/avatar/page.tsx
Normal file
209
src/app/(auth)/verify/avatar/page.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IProfileData } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// کتابخانه برای crop
|
||||
import Cropper from "react-easy-crop";
|
||||
import getCroppedImg from "@/helpers/cropImage";
|
||||
import { Area } from "react-easy-crop";
|
||||
|
||||
function AvatarPage() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [userDetail, setUserDetail] = useState<IProfileData | null>(null);
|
||||
const [avatar, setAvatar] = useState<string | null>(null);
|
||||
const [originalImage, setOriginalImage] = useState<string | null>(null); // تصویر اصلی (سایز کامل)
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [loadingUpload, setLoadingUpload] = useState(false);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await request<{ user: IProfileData }>("GET", "/profile");
|
||||
setUserDetail(response?.user || null);
|
||||
setAvatar(response?.user?.profile_image || null);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// انتخاب تصویر
|
||||
const selectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
const file = event.target.files[0];
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const imageDataUrl = e.target?.result as string;
|
||||
setAvatar(imageDataUrl); // برای کراپر
|
||||
setOriginalImage(imageDataUrl); // تصویر اصلی (سایز کامل)
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// حذف تصویر
|
||||
// const removeImage = () => {
|
||||
// setAvatar(null);
|
||||
// setOriginalImage(null);
|
||||
// };
|
||||
|
||||
// دریافت مختصات crop شده
|
||||
const onCropComplete = useCallback(
|
||||
(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("لطفا تصویر پروفایل را انتخاب کنید");
|
||||
return;
|
||||
}
|
||||
setLoadingUpload(true);
|
||||
const croppedBlob = await createCroppedImage();
|
||||
if (!croppedBlob) {
|
||||
toast.error("خطا در برش تصویر");
|
||||
setLoadingUpload(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("profile_image", croppedBlob, "avatar.jpg");
|
||||
formData.append("mobile", mobile as string);
|
||||
|
||||
try {
|
||||
await request("POST", "/verify/profile_image", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
toast.success("تصویر با موفقیت ثبت شد!");
|
||||
navigateHandler();
|
||||
} catch (err) {
|
||||
console.log("Upload error:", err);
|
||||
toast.error("خطا در ارسال تصویر!");
|
||||
} finally {
|
||||
setLoadingUpload(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ناوبری بعد از آپلود
|
||||
const navigateHandler = () => {
|
||||
if (userDetail?.user_type === "user") {
|
||||
router.push("/verify/expertise");
|
||||
} else {
|
||||
router.push("/verify/gender");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
{(loading || loadingUpload) && <p>Loading...</p>}
|
||||
|
||||
{!loading && !loadingUpload && (
|
||||
<>
|
||||
<span className="text-xl font-bold mb-4">تصویر پروفایل</span>
|
||||
|
||||
{/* نمایش تصویر اصلی (سایز کامل) */}
|
||||
{originalImage && (
|
||||
<div className="relative w-[300px] h-[400px] bg-gray-200 rounded-2xl mb-4 overflow-hidden">
|
||||
<Image
|
||||
src={originalImage}
|
||||
alt="Original image"
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* کراپر برای برش تصویر به مربع 1:1 */}
|
||||
<div className="relative w-[250px] h-[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" // نمایش کامل تصویر
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
className="mt-10"
|
||||
disabled={loadingUpload}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={navigateHandler}
|
||||
type="button"
|
||||
>
|
||||
رد کن
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default AvatarPage;
|
||||
187
src/app/(auth)/verify/colors/page.tsx
Normal file
187
src/app/(auth)/verify/colors/page.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
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(" رنگ چشم الزامی است"),
|
||||
});
|
||||
|
||||
function Colors() {
|
||||
const [selectedButton, setSelectedButton] = useState<number>(1); // Default: 1 for "قد"
|
||||
const [selectedHairImage, setSelectedHairImage] = useState<string>("");
|
||||
const [selectedEyeImage, setSelectedEyeImage] = useState<string>("");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
const [expertise] = useState<string>(
|
||||
typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : ""
|
||||
);
|
||||
|
||||
if (expertise !== "مدل") {
|
||||
router.push("/verify/public-relations");
|
||||
}
|
||||
|
||||
const handleCheck = async () => {
|
||||
try {
|
||||
await schema.validate({ selectedHairImage, selectedEyeImage });
|
||||
await sendSizesHandler();
|
||||
} catch (validationError: any) {
|
||||
toast.error(validationError.message, {
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
console.log(selectedHairImage, selectedEyeImage);
|
||||
|
||||
const sendSizesHandler = async () => {
|
||||
try {
|
||||
const data = {
|
||||
mobile,
|
||||
hair_color: selectedHairImage,
|
||||
eye_color: selectedEyeImage,
|
||||
};
|
||||
await request("POST", "/verify/colors", data);
|
||||
router.push("/verify/public-relations");
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtonPress = (buttonIndex: number) => {
|
||||
setSelectedButton(buttonIndex);
|
||||
};
|
||||
|
||||
// تصاویر رنگ چشم و رنگ مو
|
||||
const eyeImages = Array.from(
|
||||
{ length: 26 },
|
||||
(_, i) => `/images/eyes/${String(i + 1).padStart(2, "0")}.png`
|
||||
);
|
||||
const hairImages = Array.from(
|
||||
{ length: 85 },
|
||||
(_, i) => `/images/hairs/${String(i + 1).padStart(2, "0")}.png`
|
||||
);
|
||||
|
||||
// تابع برای ذخیره نام فایل انتخاب شده
|
||||
const handleImageSelect = (image: string, type: "eye" | "hair") => {
|
||||
const fileName = image.split("/").pop(); // استخراج فقط نام فایل
|
||||
if (type === "eye") {
|
||||
setSelectedEyeImage(fileName || "");
|
||||
} else if (type === "hair") {
|
||||
setSelectedHairImage(fileName || "");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">سایز</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-center text-sm font-bold">مشخصات ظاهری</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
|
||||
? "bg-[#FC8EAC] border-[#FC8EAC]"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(1)}
|
||||
>
|
||||
رنگ چشم
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "bg-[#FC8EAC] border-[#FC8EAC]"
|
||||
: "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>
|
||||
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
className="mt-10"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
</AuthNextButton>
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/public-relations")}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Colors;
|
||||
157
src/app/(auth)/verify/confirm/page.tsx
Normal file
157
src/app/(auth)/verify/confirm/page.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
|
||||
function Confirm() {
|
||||
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
const userType: string | null =
|
||||
typeof window !== "undefined" ? localStorage.getItem("usertype") : null;
|
||||
|
||||
const confirmHandler = () => {
|
||||
if (userType !== "employer") {
|
||||
setShowModal(true);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
};
|
||||
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">
|
||||
<Image
|
||||
className="rounded-2xl object-cover"
|
||||
width={280}
|
||||
height={280}
|
||||
alt={user?.user_name || "user profile"}
|
||||
src={`${IMAGE_BASE_URL}${user?.profile_image}`}
|
||||
unoptimized={true}
|
||||
/>
|
||||
<div className="flex flex-col justify-center items-center gap-1 mt-1">
|
||||
<span>
|
||||
{user?.first_name && user?.first_name + " " + user?.last_name}
|
||||
</span>
|
||||
<div className="flex flex-col justify-center items-center gap-1 mt-1">
|
||||
{user?.user_name}
|
||||
{user?.is_verified === "verified" ? (
|
||||
<Image
|
||||
width={70}
|
||||
height={70}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/verify.svg`}
|
||||
/>
|
||||
) : user?.is_verified === "pending" ? (
|
||||
<Image
|
||||
width={70}
|
||||
height={70}
|
||||
alt={"not-verify icon"}
|
||||
src={`/images/icons/not-verify.svg`}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small className="font-bold mt-10 mb-4 text-center">
|
||||
پس از تایید مدارک و احراز هویت، تیک آبی یا طلایی کنار نام کاربری شما
|
||||
قرار می گیرد
|
||||
</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>
|
||||
</div>
|
||||
{/* */}
|
||||
<AuthNextButton onClick={confirmHandler} className="mt-20">
|
||||
ثبت و ادامه
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default Confirm;
|
||||
109
src/app/(auth)/verify/cooperation-type/page.tsx
Normal file
109
src/app/(auth)/verify/cooperation-type/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
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("انتخاب نوع همکاری الزامی است"),
|
||||
});
|
||||
|
||||
function CooperationType() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedButton: null,
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
await request("POST", "/verify/cooperation_type", {
|
||||
mobile: mobile,
|
||||
cooperation_abroad: values.selectedButton,
|
||||
cooperation_type: "all",
|
||||
});
|
||||
router.push("/verify/public-relations");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "خطای نامشخصی رخ داد.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">نوع همکاری</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</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">
|
||||
<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
|
||||
? "!bg-[#FC8EAC] !border-[#FC8EAC] !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
|
||||
? "!bg-[#FC8EAC] !border-[#FC8EAC] !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>
|
||||
)}
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
</AuthNextButton>
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/public-relations")}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default CooperationType;
|
||||
151
src/app/(auth)/verify/expertise/page.tsx
Normal file
151
src/app/(auth)/verify/expertise/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
import AuthUserDetails from "@/components/auth/AuthUserDetails";
|
||||
import { IExpertise } from "@/types/types";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
subExpertise: yup
|
||||
.array()
|
||||
.of(yup.string().required("حداقل یک زیرمهارت را انتخاب کنید"))
|
||||
.min(1, "حداقل یک زیرمهارت را انتخاب کنید"),
|
||||
expertise: yup.string().required("انتخاب نوع تخصص الزامی است"),
|
||||
});
|
||||
|
||||
function Expertise() {
|
||||
const [expertise, setExpertise] = useState<string>("");
|
||||
const [expertiseList, setExpertiseList] = useState<IExpertise[] | null>(null);
|
||||
const [subExpertise, setSubExpertise] = useState<string[]>([]);
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
// Fetch expertise list
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await request<{ expertises: IExpertise[] }>(
|
||||
"GET",
|
||||
"/expertise"
|
||||
);
|
||||
setExpertiseList(response?.expertises);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleCheck = async () => {
|
||||
try {
|
||||
await schema.validate({ expertise, subExpertise });
|
||||
await sendExpertiseTypeHandler();
|
||||
} catch (validationError: any) {
|
||||
toast.error(validationError.message, {
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const sendExpertiseTypeHandler = async () => {
|
||||
try {
|
||||
const data = {
|
||||
mobile,
|
||||
expertise,
|
||||
sub_expertise: subExpertise,
|
||||
};
|
||||
await request("POST", "/verify/expertise", data);
|
||||
localStorage.setItem("expertise", expertise);
|
||||
router.push("/verify/services");
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "خطای نامشخصی رخ داد.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtonPress = (expertise: string) => {
|
||||
setExpertise(expertise);
|
||||
setSubExpertise([]);
|
||||
};
|
||||
|
||||
const handleSubButtonPress = (sub_expertise: string) => {
|
||||
const updatedSubExpertise = subExpertise.includes(sub_expertise)
|
||||
? subExpertise.filter((item) => item !== sub_expertise)
|
||||
: [...subExpertise, sub_expertise];
|
||||
setSubExpertise(updatedSubExpertise);
|
||||
};
|
||||
|
||||
const selectedExpertise = expertiseList?.find(
|
||||
(item) => item.expertise === expertise
|
||||
);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">تخصص</span>
|
||||
|
||||
<AuthUserDetails />
|
||||
|
||||
<p className="mt-8 text-center">در چه زمینه ای تخصص دارید؟</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 mt-4 max-w-[330px]">
|
||||
{expertiseList?.map((item) => (
|
||||
<button
|
||||
key={item._id}
|
||||
className={`p-1 min-w-24 rounded-3xl border-2 overflow-hidden text-xs ${
|
||||
expertise === item.expertise
|
||||
? "bg-yellow-500 text-white border-yellow-500"
|
||||
: "bg-white text-black border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(item.expertise)}
|
||||
>
|
||||
{item.expertise}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{selectedExpertise?.sub_expertise.map((item) => (
|
||||
<button
|
||||
key={item._id}
|
||||
className={`p-1 min-w-24 rounded-3xl border-2 overflow-hidden text-xs ${
|
||||
subExpertise.includes(item.name)
|
||||
? "bg-pink-500 text-white border-pink-500"
|
||||
: "bg-white text-black border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleSubButtonPress(item.name)}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
className="mt-20"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
</AuthNextButton>
|
||||
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/services")}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Expertise;
|
||||
123
src/app/(auth)/verify/gender/page.tsx
Normal file
123
src/app/(auth)/verify/gender/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
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("جنسیت الزامی است"),
|
||||
});
|
||||
|
||||
function Gender() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
const expertise =
|
||||
typeof window !== "undefined" ? localStorage.getItem("expertise") : null;
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedButton: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
await request("POST", "/verify/gender", {
|
||||
mobile: mobile,
|
||||
gender: values.selectedButton,
|
||||
});
|
||||
if (expertise == "مدلینگ") {
|
||||
router.push("/verify/sizes");
|
||||
} else {
|
||||
router.push("/verify/services");
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "خطای نامشخصی رخ داد.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">جنسیت</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
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"
|
||||
? "!bg-[#FC8EAC] !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>
|
||||
|
||||
{/* Error Message */}
|
||||
{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" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
</AuthNextButton>
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/sizes")}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Gender;
|
||||
250
src/app/(auth)/verify/location/page.tsx
Normal file
250
src/app/(auth)/verify/location/page.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
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(" انتخاب استان الزامی است"),
|
||||
});
|
||||
|
||||
function LocationPage() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const [isCheckedOne, setIsCheckedOne] = useState(false);
|
||||
const [allStates, setAllStates] = useState<IProvince[] | null>(null);
|
||||
const [cities, setCities] = useState<ICity[] | null>(null);
|
||||
const [selectedLocation, setSelectedLocation] = useState<{
|
||||
lat: number;
|
||||
lng: number;
|
||||
} | null>(null);
|
||||
|
||||
const fetchStates = async () => {
|
||||
try {
|
||||
const response = await request<{ provinces: IProvince[] }>(
|
||||
"GET",
|
||||
"/provinces"
|
||||
);
|
||||
setAllStates(response?.provinces || null);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch cities when a province is selected
|
||||
const fetchCities = async (provinceId: string) => {
|
||||
try {
|
||||
const response = await request<{ cities: ICity[] }>(
|
||||
"GET",
|
||||
`/cities/${provinceId}`
|
||||
);
|
||||
setCities(response?.cities || []);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStates();
|
||||
}, []);
|
||||
|
||||
const toggleCheckBox = () => {
|
||||
setIsCheckedOne(!isCheckedOne);
|
||||
};
|
||||
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
markerCoordinate: [],
|
||||
address: "",
|
||||
cityId: "",
|
||||
stateId: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
await request("POST", "/verify/address", {
|
||||
mobile: mobile,
|
||||
address: values.address,
|
||||
province_id: values.stateId,
|
||||
city_id: values.cityId,
|
||||
lat: values.markerCoordinate[0],
|
||||
lng: values.markerCoordinate[1],
|
||||
show_location: isCheckedOne,
|
||||
});
|
||||
router.push("/verify/national-cart");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "خطای نامشخصی رخ داد.");
|
||||
} 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
|
||||
};
|
||||
|
||||
const handleMapClick = (event: any) => {
|
||||
const { lngLat } = event;
|
||||
setSelectedLocation({
|
||||
lat: lngLat.lat,
|
||||
lng: lngLat.lng,
|
||||
});
|
||||
formik.setFieldValue("markerCoordinate", [lngLat.lng, lngLat.lat]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center address-page ">
|
||||
<span className="text-xl font-bold">لوکیشن</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
مشخص کنید در کدام شهر قادر به انجام فعالیت هستید
|
||||
</small>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
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>
|
||||
)}
|
||||
|
||||
<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}
|
||||
>
|
||||
<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" onChange={toggleCheckBox} />
|
||||
<span className="text-xs font-bold">
|
||||
اطلاعات لوکیشن شما برای همه قابل نمایش باشد
|
||||
</span>
|
||||
</label>
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
</AuthNextButton>
|
||||
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/national-cart")}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default LocationPage;
|
||||
171
src/app/(auth)/verify/national-cart/page.tsx
Normal file
171
src/app/(auth)/verify/national-cart/page.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 } from "@/components/main/BaseUrl";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
|
||||
function NationalCart() {
|
||||
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];
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setNationalCartImg(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// حذف تصویر
|
||||
const removeImage = () => setNationalCartImg(null);
|
||||
|
||||
// ارسال فرم
|
||||
const uploadImage = async () => {
|
||||
if (!nationalCartImg) {
|
||||
toast.error("لطفا تصویر کارت ملی را انتخاب کنید");
|
||||
return;
|
||||
}
|
||||
if (!isCheckedOne) {
|
||||
toast.error("لطفا قوانین را مطالعه و تایید کنید");
|
||||
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("تصویر با موفقیت ارسال شد!");
|
||||
router.push("/verify/confirm");
|
||||
} catch (err) {
|
||||
console.log("Upload error:", err);
|
||||
toast.error("خطا در ارسال تصویر!");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center address-page">
|
||||
<span className="text-xl font-bold">احراز هویت</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"
|
||||
>
|
||||
تایید قوانین و مقررات
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
className="mt-20"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
</AuthNextButton>
|
||||
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/confirm")}
|
||||
type="button"
|
||||
>
|
||||
رد کن
|
||||
</button>
|
||||
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default NationalCart;
|
||||
105
src/app/(auth)/verify/public-relations/page.tsx
Normal file
105
src/app/(auth)/verify/public-relations/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
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 الزامی است"),
|
||||
});
|
||||
|
||||
|
||||
function PublicRelations() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedButton: null,
|
||||
bio: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
await request("POST", "/verify/conversation", {
|
||||
mobile: mobile,
|
||||
conversation_projects: values.selectedButton,
|
||||
bio: values.bio,
|
||||
});
|
||||
router.push("/verify/location");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "خطای نامشخصی رخ داد.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">روابط عمومی</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</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={(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>
|
||||
|
||||
{formik.touched.bio && formik.errors.bio && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.bio}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
</AuthNextButton>
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/location")}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicRelations;
|
||||
137
src/app/(auth)/verify/services/page.tsx
Normal file
137
src/app/(auth)/verify/services/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Service } from "@/types/types";
|
||||
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 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";
|
||||
|
||||
const ServicesPage: React.FC = () => {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const [expertise] = useState<string>(
|
||||
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));
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (services.length === 0) {
|
||||
toast.error("لطفاً حداقل یک خدمت وارد کنید.");
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("services", JSON.stringify(services));
|
||||
|
||||
// اضافه کردن تصاویر خدمات
|
||||
services.forEach(({ id, image }) => {
|
||||
if (image) {
|
||||
formData.append(
|
||||
`serviceImages[${id}]`,
|
||||
dataURLtoBlob(image),
|
||||
`service-${id}.jpg`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await request("post", "/verify/services", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
toast.success("خدمات با موفقیت ثبت شدند!");
|
||||
setServices([]); // ریست لیست پس از ثبت
|
||||
|
||||
if (expertise === "مدل") {
|
||||
router.push("/verify/sizes");
|
||||
} else {
|
||||
router.push("/verify/public-relations");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error("خطا در ثبت خدمات!");
|
||||
}
|
||||
};
|
||||
|
||||
const Reject = () => {
|
||||
if (expertise === "مدل") {
|
||||
router.push("/verify/sizes");
|
||||
} else {
|
||||
router.push("/verify/public-relations");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center w-full">
|
||||
<span className="text-xl font-bold">خدمات</span>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="flex gap-4 mt-6">
|
||||
<AuthNextButton
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="mb-4 w-32"
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={Reject}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServicesPage;
|
||||
199
src/app/(auth)/verify/sizes/page.tsx
Normal file
199
src/app/(auth)/verify/sizes/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
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("وارد کردن قد الزامی است"),
|
||||
});
|
||||
|
||||
const sizes = ["34", "36", "38", "40", "42", "44", "46", "48", "50"];
|
||||
|
||||
function Sizes() {
|
||||
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 router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const [expertise] = useState<string>(
|
||||
typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : ""
|
||||
);
|
||||
|
||||
if (expertise !== "مدل") {
|
||||
router.push("/verify/public-relations");
|
||||
}
|
||||
|
||||
const handleCheck = async () => {
|
||||
try {
|
||||
await schema.validate({ height, weight, size });
|
||||
await sendSizesHandler();
|
||||
} catch (validationError: any) {
|
||||
toast.error(validationError.message, {
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const sendSizesHandler = async () => {
|
||||
try {
|
||||
const data = {
|
||||
mobile,
|
||||
height,
|
||||
weight,
|
||||
size,
|
||||
};
|
||||
await request("POST", "/verify/sizes", data);
|
||||
router.push("/verify/colors");
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "خطای نامشخصی رخ داد.");
|
||||
}
|
||||
};
|
||||
|
||||
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">سایز</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-center text-sm font-bold">مشخصات ظاهری</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
|
||||
? "bg-[#FC8EAC] border-[#FC8EAC]"
|
||||
: "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
|
||||
? "bg-[#FC8EAC] border-[#FC8EAC]"
|
||||
: "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
|
||||
? "bg-[#FC8EAC] border-[#FC8EAC]"
|
||||
: "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
|
||||
? "bg-[#FC8EAC] text-white border-[#FC8EAC]"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleSizeSelection(sizeOption)}
|
||||
>
|
||||
{sizeOption}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
className="mt-10"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
</AuthNextButton>
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/colors")}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sizes;
|
||||
96
src/app/(projects)/academy/[[...slug]]/page.tsx
Normal file
96
src/app/(projects)/academy/[[...slug]]/page.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import Container from "@/components/elements/Container";
|
||||
import { cookies } from "next/headers";
|
||||
import InfinitePosts from "@/components/academy/InfinitePakage";
|
||||
import { Metadata } from "next";
|
||||
import AcademyFilter from "@/components/academy/AcademyFilter";
|
||||
|
||||
interface IProjectsProps {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
searchParams: Promise<{
|
||||
search?: string;
|
||||
category?: string;
|
||||
minPrice?: string;
|
||||
maxPrice?: string;
|
||||
hasOffer?: string;
|
||||
type?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: IProjectsProps): Promise<Metadata> {
|
||||
const filters = await searchParams;
|
||||
|
||||
// خواندن مقادیر فیلترها (اگر خالی بود مقدار پیشفرض در نظر گرفته میشه)
|
||||
const categoryLabel = filters.category || "حوزه زیبایی";
|
||||
const typeLabel = filters.type ? ` به صورت ${filters.type}` : "";
|
||||
const descTypeLabel = filters.type ? ` بصورت ${filters.type}` : "";
|
||||
|
||||
// مقادیر پیشفرض زمانی که هیچ فیلتری انتخاب نشده
|
||||
let title = "آموزش آنلاین انواع خدمات حوزه زیبایی و مدلینگ - مدستاگرام";
|
||||
let description = "آموزش آنلاین تخصصی و حرفهای از انواع اساتید حوزه زیبایی در همه زمینهها اعم از آرایشگری، مدلینگ و عکاسی - مدستاگرام";
|
||||
|
||||
// اگر کاربر فیلتر "دسته بندی" یا "نوع" رو انتخاب کرده بود، متنها داینامیک میشن
|
||||
if (filters.category || filters.type) {
|
||||
title = `آموزش آنلاین انواع خدمات ${categoryLabel}${typeLabel} - مدستاگرام`;
|
||||
description = `آموزش آنلاین تخصصی و حرفه ای خدمات ${categoryLabel}${descTypeLabel} در مدستاگرام`;
|
||||
}
|
||||
|
||||
// ساخت آدرس URL داینامیک بر اساس فیلترها
|
||||
let canonicalUrl = "https://modstagram.com/academy";
|
||||
const queryParams = new URLSearchParams();
|
||||
if (filters.category) queryParams.append("category", filters.category);
|
||||
if (filters.type) queryParams.append("type", filters.type);
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
if (queryString) {
|
||||
canonicalUrl += `?${queryString}`;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: canonicalUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Projects({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
search?: string;
|
||||
category?: string;
|
||||
minPrice?: string;
|
||||
maxPrice?: string;
|
||||
hasOffer?: string;
|
||||
type?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
}>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("token")?.value || "";
|
||||
|
||||
const filters = {
|
||||
search: params.search || "",
|
||||
category: params.category || "",
|
||||
minPrice: params.minPrice ? Number(params.minPrice) : undefined,
|
||||
maxPrice: params.maxPrice ? Number(params.maxPrice) : undefined,
|
||||
hasOffer: params.hasOffer === "true",
|
||||
type: params.type || "",
|
||||
sortBy: params.sortBy || "createdAt",
|
||||
sortOrder: params.sortOrder || "desc",
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<AcademyFilter />
|
||||
<InfinitePosts filters={filters} token={token} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
923
src/app/(projects)/academy/[id]/[title]/CourseClient.tsx
Normal file
923
src/app/(projects)/academy/[id]/[title]/CourseClient.tsx
Normal file
@@ -0,0 +1,923 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
import { toast } from "react-hot-toast";
|
||||
import {
|
||||
Lock,
|
||||
Play,
|
||||
CheckCircle,
|
||||
ShoppingCart,
|
||||
Clock,
|
||||
User,
|
||||
BookOpen,
|
||||
Percent,
|
||||
Tag,
|
||||
FileVideo,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { Course } from "@/types/types";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { Comments } from "@/components/academy/CommentsModal";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
// ==================== دیتای فیک ====================
|
||||
const MOCK_COURSE = {
|
||||
_id: "course_123",
|
||||
cuorse_name: "در حال بارگذاری...",
|
||||
price: 0,
|
||||
category: "در حال بارگذاری...",
|
||||
offer: 0,
|
||||
caption: "در حال بارگذاری...",
|
||||
course_time: "در حال بارگذاری...",
|
||||
teacher_name: "در حال بارگذاری...",
|
||||
teacher_number: "در حال بارگذاری...",
|
||||
course_image: "در حال بارگذاری...",
|
||||
number_of_course_content: "0",
|
||||
averageRate: 0,
|
||||
};
|
||||
|
||||
const MOCK_VIDEOS = [
|
||||
{
|
||||
_id: "video_1",
|
||||
file_name: "در حال بارگذاری...",
|
||||
course_video: "/videos/sample1.mp4",
|
||||
is_free: false,
|
||||
type: "video",
|
||||
duration: "در حال بارگذاری...",
|
||||
}
|
||||
|
||||
];
|
||||
|
||||
// کامپوننت نمایش ریتینگ
|
||||
const RatingStars = ({ rate }: { rate: number }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-4 h-4 ${
|
||||
i < Math.floor(rate)
|
||||
? "text-yellow-400 fill-yellow-400"
|
||||
: "text-gray-300"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
<span className="text-sm text-gray-600 mr-2">({rate.toFixed(1)})</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CourseDetailProps {
|
||||
courseId?: string;
|
||||
initialIsPurchased?: boolean;
|
||||
}
|
||||
|
||||
interface MediaFile {
|
||||
id: string;
|
||||
title: string;
|
||||
file: File | null;
|
||||
preview?: string;
|
||||
course_video: string;
|
||||
type: "image" | "video";
|
||||
isFree?: boolean;
|
||||
is_free?: boolean;
|
||||
}
|
||||
|
||||
export default function CourseDetail({
|
||||
courseId,
|
||||
initialIsPurchased = false,
|
||||
}: CourseDetailProps) {
|
||||
const [selectedVideo, setSelectedVideo] = useState(MOCK_VIDEOS[0]);
|
||||
const [isPurchased, setIsPurchased] = useState(initialIsPurchased);
|
||||
const [purchasing, setPurchasing] = useState(false);
|
||||
const [videos] = useState(MOCK_VIDEOS);
|
||||
const [course] = useState(MOCK_COURSE);
|
||||
|
||||
const pathname = usePathname();
|
||||
const match = pathname.match(/\/academy\/([^\/]+)/);
|
||||
const id = match ? match[1] : null;
|
||||
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const { request } = useAxios();
|
||||
|
||||
const _id = courses[0]?._id;
|
||||
|
||||
const [comments, setComments] = useState<Comments[]>([]);
|
||||
const [totalComments, setTotalComments] = useState(0);
|
||||
const [averageRate, setAverageRate] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState<string>("accepted");
|
||||
const [sortBy, setSortBy] = useState<string>("createdAt");
|
||||
const [sortOrder, setSortOrder] = useState<string>("desc");
|
||||
const [usersCache, setUsersCache] = useState<Record<string, any>>({});
|
||||
const [videoFields, setVideoFields] = useState<MediaFile[]>([]);
|
||||
|
||||
const [newScore, setNewScore] = useState<number>(0);
|
||||
const [rating, setRating] = useState<number>(0);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const getAcademyCourses = async () => {
|
||||
|
||||
|
||||
try {
|
||||
const getAcademyCourses = (await request(
|
||||
"GET",
|
||||
`/academy/academy/course/content?courseId=${id}&page=1&limit=20`,
|
||||
{}
|
||||
)) as MediaFile;
|
||||
|
||||
setVideoFields(getAcademyCourses.data.courses);
|
||||
setSelectedVideo(getAcademyCourses.data.courses[0])
|
||||
} catch (err) {
|
||||
console.log("get error:", err);
|
||||
toast.error("خطا در دریافت اطلاعات");
|
||||
} finally {
|
||||
}
|
||||
};
|
||||
|
||||
getAcademyCourses();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const getAcademyCourses = async () => {
|
||||
try {
|
||||
const getCoursespayment = await request(
|
||||
"GET", // تغییر به GET
|
||||
`/academy/course/checkCoursePurchase/${id}` // ارسال ID در مسیر
|
||||
);
|
||||
|
||||
console.log(getCoursespayment);
|
||||
|
||||
if (getCoursespayment?.success) {
|
||||
if (getCoursespayment.isPurchased) {
|
||||
console.log("دوره خریداری شده است");
|
||||
setIsPurchased(true)
|
||||
} else {
|
||||
console.log("دوره خریداری نشده است");
|
||||
setIsPurchased(false)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.log("get error:", err);
|
||||
toast.error("خطا در دریافت اطلاعات");
|
||||
}
|
||||
};
|
||||
|
||||
getAcademyCourses();
|
||||
}, [id]);
|
||||
|
||||
// تابع دریافت اطلاعات کاربر با caching
|
||||
const fetchUserInfo = async (userId: string) => {
|
||||
if (usersCache[userId]) {
|
||||
return usersCache[userId];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request("GET", `/profile/${userId}`);
|
||||
if (response?.user) {
|
||||
const userData = response.user;
|
||||
setUsersCache((prev) => ({ ...prev, [userId]: userData }));
|
||||
return userData;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching user ${userId}:`, error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// دریافت تعداد کل کامنتها و آمار
|
||||
const fetchCommentsStats = async () => {
|
||||
if (!_id) return;
|
||||
|
||||
try {
|
||||
const response = await request(
|
||||
"GET",
|
||||
`/academy/course/${_id}/comments/count`
|
||||
);
|
||||
if (response?.success) {
|
||||
setTotalComments(response.data.totalComments || 0);
|
||||
setAverageRate(response.data.averageRate || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching stats:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// دریافت لیست کامنتها
|
||||
const fetchComments = async (pageNum: number, append = false) => {
|
||||
if (loading || !_id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await request(
|
||||
"GET",
|
||||
`/academy/course/${_id}/comments?page=${pageNum}&limit=20&status=${statusFilter}&sortBy=${sortBy}&sortOrder=${sortOrder}`
|
||||
);
|
||||
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
|
||||
response.data.stats.rateDistribution.forEach(element => {
|
||||
sum += Number(element);
|
||||
count++;
|
||||
});
|
||||
|
||||
const average = sum / count;
|
||||
const averagesum = sum *1;
|
||||
|
||||
const roundedRating = Math.round(average * 10) / 10
|
||||
setRating(roundedRating);
|
||||
setNewScore(averagesum);
|
||||
|
||||
if (response?.success) {
|
||||
let newComments = response.data.comments;
|
||||
const pagination = response.data.pagination;
|
||||
|
||||
if (newComments && newComments.length > 0) {
|
||||
// بررسی اینکه آیا اطلاعات کاربر در کامنتها وجود دارد
|
||||
if (newComments[0]?.user_id?.user_name) {
|
||||
// اطلاعات کاربر قبلاً داخل کامنت است
|
||||
setComments((prev) =>
|
||||
append ? [...prev, ...newComments] : newComments
|
||||
);
|
||||
} else {
|
||||
// اگر بکاند فقط user_id را برگردانده، اطلاعات کاربر را بگیر
|
||||
const uniqueUserIds = [...new Set(newComments.map((c) => c.user_id))];
|
||||
|
||||
// دریافت اطلاعات همه کاربران به صورت همزمان
|
||||
const usersData = await Promise.all(
|
||||
uniqueUserIds.map((id) => fetchUserInfo(id))
|
||||
);
|
||||
|
||||
// ساخت mapping از userId به اطلاعات کاربر
|
||||
const userMap: Record<string, any> = {};
|
||||
uniqueUserIds.forEach((userId, index) => {
|
||||
userMap[userId] = usersData[index] || {
|
||||
_id: userId,
|
||||
user_name: "کاربر ناشناس",
|
||||
profile_image: null,
|
||||
is_verified: "unverified"
|
||||
};
|
||||
});
|
||||
|
||||
// ترکیب کامنتها با اطلاعات کاربران
|
||||
const commentsWithUsers = newComments.map((comment) => ({
|
||||
...comment,
|
||||
user_id: userMap[comment.user_id],
|
||||
}));
|
||||
|
||||
setComments((prev) =>
|
||||
append ? [...prev, ...commentsWithUsers] : commentsWithUsers
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!append) setComments([]);
|
||||
}
|
||||
|
||||
setHasMore(pagination?.hasNextPage || false);
|
||||
if (pagination?.totalItems) {
|
||||
setTotalComments(pagination.totalItems);
|
||||
}
|
||||
|
||||
// بهروزرسانی averageRate از اطلاعات بازگشتی
|
||||
if (response.data.stats?.averageRate) {
|
||||
setAverageRate(response.data.stats.averageRate);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching comments:", error);
|
||||
toast.error("خطا در دریافت نظرات");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// لود اولیه و هنگام تغییر فیلترها
|
||||
useEffect(() => {
|
||||
if (_id) {
|
||||
setPage(1);
|
||||
setComments([]);
|
||||
fetchComments(1, false);
|
||||
fetchCommentsStats();
|
||||
}
|
||||
}, [_id, statusFilter, sortBy, sortOrder]);
|
||||
|
||||
// اینفینیت اسکرول
|
||||
const observer = useRef<IntersectionObserver | null>(null);
|
||||
const lastCommentRef = useCallback(
|
||||
(node: HTMLDivElement) => {
|
||||
if (loading) return;
|
||||
if (observer.current) observer.current.disconnect();
|
||||
observer.current = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && hasMore) {
|
||||
setPage((prev) => prev + 1);
|
||||
}
|
||||
});
|
||||
if (node) observer.current.observe(node);
|
||||
},
|
||||
[loading, hasMore]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (page > 1) {
|
||||
fetchComments(page, true);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
const getAcademyCourses = async () => {
|
||||
try {
|
||||
const response = await request(
|
||||
"GET",
|
||||
`/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}`
|
||||
);
|
||||
|
||||
if (response?.success && response?.data?.courses) {
|
||||
setCourses(response.data.courses);
|
||||
} else if (response?.data && Array.isArray(response.data)) {
|
||||
setCourses(response.data);
|
||||
} else if (Array.isArray(response)) {
|
||||
setCourses(response);
|
||||
} else {
|
||||
console.error("Unexpected response structure:", response);
|
||||
setCourses([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("get error:", err);
|
||||
toast.error("خطا در دریافت اطلاعات");
|
||||
setCourses([]);
|
||||
}
|
||||
};
|
||||
|
||||
if (id) {
|
||||
getAcademyCourses();
|
||||
}
|
||||
}, [id, request]);
|
||||
|
||||
// خرید دوره
|
||||
// const handlePurchase = async () => {
|
||||
// setPurchasing(true);
|
||||
|
||||
// try {
|
||||
// const response = await request(
|
||||
// "POST",
|
||||
// "/academy/academy/course/payment-web",
|
||||
// {
|
||||
// courseId: id, // یا _id که از courses[0]?._id میآید
|
||||
// }
|
||||
// );
|
||||
|
||||
// console.log("پاسخ کامل سرور:", response);
|
||||
|
||||
// // بررسی موفقیت آمیز بودن درخواست
|
||||
// if (response?.success && response?.paymentUrl) {
|
||||
// // هدایت کاربر به درگاه پرداخت زرینپال
|
||||
// window.location.href = response.paymentUrl;
|
||||
// } else {
|
||||
// // نمایش خطا به کاربر
|
||||
// toast.error(response?.message || "خطا در اتصال به درگاه پرداخت");
|
||||
// setPurchasing(false);
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error("خطا در خرید:", error);
|
||||
// toast.error("خطا در ارتباط با سرور");
|
||||
// setPurchasing(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
|
||||
const handlePurchase = async (plan: {
|
||||
duration: string;
|
||||
label: string;
|
||||
price: number;
|
||||
discount?: number;
|
||||
}) => {
|
||||
let loadingToastId = null;
|
||||
|
||||
try {
|
||||
|
||||
|
||||
const token = Cookies.get("token");
|
||||
|
||||
if (!token) {
|
||||
toast.error("لطفاً ابتدا وارد حساب کاربری خود شوید");
|
||||
return
|
||||
}
|
||||
|
||||
// نمایش لودینگ
|
||||
loadingToastId = toast.loading("در حال اتصال به درگاه پرداخت...");
|
||||
|
||||
console.log("ارسال درخواست پرداخت:", {
|
||||
planDuration: plan.duration,
|
||||
planLabel: plan.label,
|
||||
price: plan.price
|
||||
});
|
||||
|
||||
const response = await request(
|
||||
"POST",
|
||||
"/academy/academy/course/payment-web",
|
||||
{
|
||||
courseId: id, // یا _id که از courses[0]?._id میآید
|
||||
}
|
||||
);
|
||||
|
||||
// بستن لودینگ
|
||||
toast.dismiss(loadingToastId);
|
||||
|
||||
console.log("پاسخ کامل سرور:", response);
|
||||
|
||||
// پردازش پاسخ (ساختارهای مختلف احتمالی)
|
||||
let authority = null;
|
||||
let paymentUrl = null;
|
||||
|
||||
if (response?.data?.authority) {
|
||||
authority = response.data.authority;
|
||||
paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
} else if (response?.authority) {
|
||||
authority = response.authority;
|
||||
paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
} else if (response?.data?.paymentUrl) {
|
||||
paymentUrl = response.data.paymentUrl;
|
||||
} else if (response?.paymentUrl) {
|
||||
paymentUrl = response.paymentUrl;
|
||||
}
|
||||
|
||||
if (paymentUrl) {
|
||||
console.log("هدایت به درگاه:", paymentUrl);
|
||||
|
||||
// باز کردن درگاه پرداخت
|
||||
const newWindow = window.open(paymentUrl, "_blank");
|
||||
|
||||
if (!newWindow) {
|
||||
toast.error("پاپآپ مسدود شده است. لطفاً اجازه باز کردن پنجره جدید را بدهید.");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("به درگاه پرداخت هدایت شدید");
|
||||
|
||||
|
||||
|
||||
} else {
|
||||
console.error("ساختار پاسخ نامعتبر:", response);
|
||||
toast.error(response?.data?.message || response?.message || "خطا در دریافت اطلاعات پرداخت");
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
// بستن لودینگ در صورت خطا
|
||||
if (loadingToastId) {
|
||||
toast.dismiss(loadingToastId);
|
||||
}
|
||||
|
||||
console.error("خطا در شروع پرداخت:", err);
|
||||
|
||||
// استخراج پیام خطا
|
||||
let errorMessage = "خطا در شروع پرداخت";
|
||||
|
||||
if (err?.response?.data?.message) {
|
||||
errorMessage = err.response.data.message;
|
||||
} else if (err?.response?.data?.error) {
|
||||
errorMessage = err.response.data.error;
|
||||
} else if (err?.message) {
|
||||
errorMessage = err.message;
|
||||
}
|
||||
|
||||
// نمایش خطاهای خاص زرینپال
|
||||
if (errorMessage.includes("amount") || errorMessage.includes("مبلغ")) {
|
||||
errorMessage = "مبلغ پرداختی نامعتبر است";
|
||||
} else if (errorMessage.includes("merchant")) {
|
||||
errorMessage = "خطا در تنظیمات درگاه پرداخت";
|
||||
}
|
||||
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// پخش ویدیو
|
||||
const handlePlayVideo = (video: (typeof MOCK_VIDEOS)[0]) => {
|
||||
if (!video.is_free && !isPurchased) {
|
||||
toast.error("برای مشاهده این ویدیو باید دوره را خریداری کنید");
|
||||
return;
|
||||
}
|
||||
setSelectedVideo(video);
|
||||
toast.success(`در حال پخش: ${video.file_name}`);
|
||||
};
|
||||
|
||||
// جلوگیری از دانلود ویدیو
|
||||
const preventDownload = (
|
||||
e:
|
||||
| React.MouseEvent<HTMLVideoElement>
|
||||
| React.KeyboardEvent<HTMLVideoElement>
|
||||
) => {
|
||||
e.preventDefault();
|
||||
toast.error("دانلود این ویدیو امکان پذیر نیست");
|
||||
};
|
||||
|
||||
const finalPrice = courses[0]?.offerNumber > 0
|
||||
? courses[0]?.price * (1 - courses[0]?.offerNumber / 100)
|
||||
: course.price;
|
||||
|
||||
const freeVideosCount = videos.filter((v) => v.is_free).length;
|
||||
const paidVideosCount = videos.filter((v) => !v.is_free).length;
|
||||
|
||||
// کامپوننت نمایش ستارههای کوچک برای کامنتها
|
||||
const renderSmallStars = (rate: number) => {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-3 h-3 ${
|
||||
star <= rate ? "text-yellow-400 fill-yellow-400" : "text-gray-300"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// در صفحه موفقیت پرداخت
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const paymentStatus = params.get('payment');
|
||||
|
||||
if (paymentStatus === 'success') {
|
||||
toast.success("پرداخت با موفقیت انجام شد");
|
||||
setIsPurchased(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white dark:bg-neutral-950">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* سمت راست - پخش ویدیو و اطلاعات */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* پلیر ویدیو */}
|
||||
<div className="bg-black rounded-xl overflow-hidden shadow-lg">
|
||||
<div className="relative">
|
||||
<video
|
||||
key={selectedVideo?._id || ""}
|
||||
className="w-full aspect-video"
|
||||
controls
|
||||
controlsList="nodownload noplaybackrate"
|
||||
onContextMenu={preventDownload}
|
||||
onKeyDown={preventDownload}
|
||||
disablePictureInPicture
|
||||
>
|
||||
<source src={"https://app.modstagram.ir" + selectedVideo?.course_video} type="video/mp4" />
|
||||
مرورگر شما از پخش ویدیو پشتیبانی نمیکند
|
||||
</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">
|
||||
این ویدیو رایگان نیست
|
||||
</p>
|
||||
<p className="text-gray-300 text-sm mb-6">
|
||||
برای مشاهده تمام ویدیوها دوره را تهیه کنید
|
||||
</p>
|
||||
<button
|
||||
onClick={handlePurchase}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-8 py-3 rounded-lg flex items-center gap-2 font-bold transition-colors"
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
خرید دوره با {Math.round(finalPrice).toLocaleString()} تومان
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-neutral-200 dark:bg-neutral-800 text-black dark:text-white">
|
||||
<h3 className="font-bold text-lg">{selectedVideo?.file_name || ""}</h3>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* توضیحات دوره */}
|
||||
<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]" />
|
||||
درباره دوره
|
||||
</h2>
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
{courses[0]?.caption || course.caption}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* نظرات */}
|
||||
<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" />
|
||||
نظرات دانشجویان
|
||||
{totalComments > 0 && (
|
||||
<span className="text-sm text-gray-500">({totalComments} نظر)</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{/* نمایش میانگین ریتینگ */}
|
||||
<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>
|
||||
<RatingStars rate={rating || course.averageRate} />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">
|
||||
{(rating || course.averageRate).toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-4 max-h-[600px]"
|
||||
>
|
||||
{!loading && comments.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-10">
|
||||
هنوز نظری ثبت نشده است. اولین نفری باشید که نظر میدهید!
|
||||
</p>
|
||||
) : (
|
||||
comments.map((item, index) => (
|
||||
<div
|
||||
key={item?._id || index}
|
||||
ref={
|
||||
index === comments.length - 1 ? lastCommentRef : null
|
||||
}
|
||||
className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{/* آواتار کاربر */}
|
||||
<div className="flex-shrink-0">
|
||||
<Image
|
||||
className="rounded-full object-cover"
|
||||
width={40}
|
||||
height={40}
|
||||
alt={item?.user_id?.user_name || "کاربر"}
|
||||
src={
|
||||
item?.user_id?.profile_image
|
||||
? `${IMAGE_BASE_URL}${item.user_id.profile_image}`
|
||||
: "/images/default-avatar.png"
|
||||
}
|
||||
priority={true}
|
||||
unoptimized={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* اطلاعات کاربر و امتیاز */}
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
<span className="font-semibold text-sm dark:text-white">
|
||||
{item?.user_id?.user_name || "کاربر ناشناس"}
|
||||
</span>
|
||||
|
||||
{item?.user_id?.is_verified === "verified" && (
|
||||
<Image
|
||||
width={16}
|
||||
height={16}
|
||||
alt="verify"
|
||||
src="/images/icons/verify.svg"
|
||||
className="inline"
|
||||
/>
|
||||
)}
|
||||
|
||||
{item?.rate > 0 && (
|
||||
<div className="mr-2">
|
||||
{renderSmallStars(item.rate)}
|
||||
<span className="text-xs text-yellow-600 mr-1">
|
||||
({item.rate})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* متن کامنت */}
|
||||
<p className="text-sm mt-1 leading-relaxed text-gray-700 dark:text-gray-300">
|
||||
{item.comment}
|
||||
</p>
|
||||
|
||||
{/* متادیتا */}
|
||||
<div className="flex gap-3 mt-2 text-xs text-gray-400">
|
||||
<span>
|
||||
{item.createdAt
|
||||
? new Date(item.createdAt).toLocaleDateString("fa-IR")
|
||||
: "تاریخ نامشخص"}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
❤️ {item.likes || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<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">
|
||||
در حال بارگذاری نظرات...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* سمت چپ - اطلاعات دوره و لیست ویدیوها */}
|
||||
<div className="space-y-6">
|
||||
{/* تصویر و اطلاعات اصلی */}
|
||||
<div className="bg-neutral-200 dark:bg-neutral-800 text-black dark:text-white rounded-xl overflow-hidden shadow-sm top-8">
|
||||
<div className="relative h-48 bg-gradient-to-r from-[#FF107D] to-[#FF107D50]">
|
||||
{courses[0]?.course_image && (
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + courses[0]?.course_image}
|
||||
alt={courses[0]?.cuorse_name || "course"}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-3">
|
||||
{courses[0]?.cuorse_name || course.cuorse_name}
|
||||
</h1>
|
||||
|
||||
{/* نمایش امتیاز دوره */}
|
||||
<div className="mb-4">
|
||||
<RatingStars rate={rating || course.averageRate} />
|
||||
</div>
|
||||
|
||||
{/* قیمت */}
|
||||
<div className="mb-4">
|
||||
{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()} تومان
|
||||
</span>
|
||||
<span className="text-sm line-through text-gray-400">
|
||||
{Math.round(courses[0]?.price).toLocaleString()} تومان
|
||||
</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}% تخفیف
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-3xl font-bold text-[#FF107D]">
|
||||
{Math.round(courses[0]?.price || course.price).toLocaleString()} تومان
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* دکمه خرید */}
|
||||
{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>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handlePurchase}
|
||||
disabled={purchasing}
|
||||
className="w-full bg-[#FF107D] hover:bg-[#FF107D80] text-white py-3 rounded-lg font-bold flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
{purchasing ? "در حال اتصال به درگاه..." : "خرید دوره"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* مشخصات دوره */}
|
||||
<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>
|
||||
</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}
|
||||
</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}
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* لیست ویدیوها */}
|
||||
<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]" />
|
||||
سرفصلهای دوره
|
||||
<span className="text-sm text-gray-400 mr-2">
|
||||
({courses[0]?.number_of_course_content} ویدیو)
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2 max-h-[500px] no-scrollbar overflow-y-auto pr-2">
|
||||
{videoFields.map((video, index) => {
|
||||
const isLocked = !video.is_free && !isPurchased;
|
||||
const isActive = selectedVideo?._id === video._id;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={video._id}
|
||||
onClick={() => handlePlayVideo(video)}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className={`w-full text-right p-3 rounded-lg flex items-center justify-between transition-all ${
|
||||
isActive
|
||||
? "bg-[#FF107D20] border-r-4 border-[#FF107D]"
|
||||
: "hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isLocked ? (
|
||||
<Lock className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
isActive
|
||||
? "bg-[#FF107D] text-white"
|
||||
: "bg-gray-200 dark:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<Play
|
||||
className={`w-4 h-4 ${
|
||||
isActive ? "text-white" : "text-gray-500"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
isLocked
|
||||
? "text-gray-400"
|
||||
: isActive
|
||||
? "text-[#FF107D]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{index + 1}. {video.file_name}
|
||||
</p>
|
||||
<span className="text-xs text-gray-400">
|
||||
{video.duration}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
رایگان
|
||||
</span>
|
||||
)}
|
||||
{isLocked && <Lock className="w-4 h-4 text-gray-400" />}
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/app/(projects)/academy/[id]/[title]/page.tsx
Normal file
54
src/app/(projects)/academy/[id]/[title]/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Metadata } from "next";
|
||||
import CourseDetail from "./CourseClient";
|
||||
|
||||
// نوع Props را بهروز کردیم که params یک Promise باشد
|
||||
type Props = {
|
||||
params: Promise<{ id: string; title: string }>;
|
||||
};
|
||||
|
||||
// تابع metadata با await روی params
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
try {
|
||||
const { id } = await params; // اول await میکنیم، بعد id میگیریم
|
||||
|
||||
const res = await fetch(
|
||||
`https://app.modstagram.ir/api/v1/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
|
||||
const response = await res.json();
|
||||
const course =
|
||||
response?.data?.courses?.[0] ||
|
||||
response?.courses?.[0] ||
|
||||
response?.[0];
|
||||
|
||||
if (course) {
|
||||
const courseName = course.cuorse_name || "دوره آموزشی";
|
||||
const category = course.category || "دستهبندی نامشخص";
|
||||
const teacher = course.teacher_name || "مدرس نامشخص";
|
||||
|
||||
const rawCaption = course.caption || "";
|
||||
const cleanCaption = rawCaption
|
||||
.replace(/<[^>]*>?/gm, "")
|
||||
.substring(0, 150);
|
||||
|
||||
return {
|
||||
title: `${courseName} - ${category} - مدستاگرام`,
|
||||
description: `${category} - ${cleanCaption} - مدرس: ${teacher} - مدستاگرام`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("خطا در دریافت اطلاعات سئو:", error);
|
||||
}
|
||||
|
||||
return {
|
||||
title: "جزئیات دوره - مدستاگرام",
|
||||
description: "توضیحات دوره آکادمی - مدستاگرام",
|
||||
};
|
||||
}
|
||||
|
||||
// صفحه اصلی را async کردیم تا بتوانیم params را await کنیم
|
||||
export default async function CoursePage({ params }: Props) {
|
||||
const { id } = await params; // استخراج id از params
|
||||
return <CourseDetail courseId={id} />;
|
||||
}
|
||||
12
src/app/(projects)/academy/layout.tsx
Normal file
12
src/app/(projects)/academy/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<TabNavigation currentPage="/academy" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
120
src/app/(projects)/academy/payment/failed/page.tsx
Normal file
120
src/app/(projects)/academy/payment/failed/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import { IProjectType, Project } from "@/types/types";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import MainProjectCard from "@/components/projects/MainProjectCard";
|
||||
|
||||
function FailedProject() {
|
||||
const router = useRouter();
|
||||
const { request } = useAxios();
|
||||
const searchParams = useSearchParams();
|
||||
const projectId = searchParams.get("projectId");
|
||||
|
||||
const [project, setProject] = useState<Project>();
|
||||
const [typeList, setTypeList] = useState<IProjectType[] | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
useEffect(() => {
|
||||
const fetchAd = async () => {
|
||||
const response = await request<{ project: Project }>(
|
||||
"GET",
|
||||
`/projects/get/web/${projectId}`
|
||||
);
|
||||
setProject(response?.project);
|
||||
};
|
||||
const fetchStates = async () => {
|
||||
try {
|
||||
const response = await request<{
|
||||
projectTypes: IProjectType[];
|
||||
}>("GET", "/projects/types");
|
||||
setTypeList(response?.projectTypes || null);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
fetchAd();
|
||||
fetchStates();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (project?.project_type && typeList) {
|
||||
const foundType = typeList.find(
|
||||
(item) => item.name === project.project_type
|
||||
);
|
||||
if (foundType) {
|
||||
setPrice(String(foundType.price)); // فرض بر اینکه price عددی است و نیاز به تبدیل به رشته دارد
|
||||
}
|
||||
}
|
||||
}, [project, typeList]);
|
||||
|
||||
const payHandler = async () => {
|
||||
try {
|
||||
const response = await request<{ authority?: string; type?: string }>(
|
||||
"POST",
|
||||
"/projects/initiate-payment-web",
|
||||
{
|
||||
item_name: project?.project_type, // Pass advertisingId to initiate payment
|
||||
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
|
||||
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] ">
|
||||
پرداخت ناموفق
|
||||
</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">
|
||||
{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()} تومان
|
||||
</RoundedDiv>
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
مبلغ قابل پرداخت: {Number(price).toLocaleString()} تومان
|
||||
</RoundedDiv>
|
||||
<RoundedButton
|
||||
onClick={() => {
|
||||
payHandler();
|
||||
}}
|
||||
className="w-32 h-9 !border-[#0C8002] text-[#0C8002]"
|
||||
>
|
||||
پرداخت مجدد
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default FailedProject;
|
||||
93
src/app/(projects)/academy/payment/success/page.tsx
Normal file
93
src/app/(projects)/academy/payment/success/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import { IProjectType, Project } from "@/types/types";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import MainProjectCard from "@/components/projects/MainProjectCard";
|
||||
|
||||
function SuccessProject() {
|
||||
const { request } = useAxios();
|
||||
const searchParams = useSearchParams();
|
||||
const projectId = searchParams.get("projectId");
|
||||
|
||||
const [project, setProject] = useState<Project>();
|
||||
const [typeList, setTypeList] = useState<IProjectType[] | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
useEffect(() => {
|
||||
const fetchAd = async () => {
|
||||
const response = await request<{ project: Project }>(
|
||||
"GET",
|
||||
`/projects/get/web/${projectId}`
|
||||
);
|
||||
setProject(response?.project);
|
||||
};
|
||||
const fetchStates = async () => {
|
||||
try {
|
||||
const response = await request<{
|
||||
projectTypes: IProjectType[];
|
||||
}>("GET", "/projects/types");
|
||||
setTypeList(response?.projectTypes || null);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
fetchAd();
|
||||
fetchStates();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (project?.project_type && typeList) {
|
||||
const foundType = typeList.find(
|
||||
(item) => item.name === project.project_type
|
||||
);
|
||||
if (foundType) {
|
||||
setPrice(String(foundType.price)); // فرض بر اینکه 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] ">
|
||||
پرداخت موفق
|
||||
</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">
|
||||
{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()} تومان
|
||||
</RoundedDiv>
|
||||
<p className="my-5">
|
||||
پروژه شما پس از بررسی توسط کارشناسان ما منتشر خواهد شد
|
||||
</p>
|
||||
<Link href={"/settings/workroom"}>
|
||||
<RoundedButton className="w-32 h-9 !border-[#0C8002] text-[#0C8002]">
|
||||
اتاق کار
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SuccessProject;
|
||||
381
src/app/(projects)/academy/profile/[academyId]/page.tsx
Normal file
381
src/app/(projects)/academy/profile/[academyId]/page.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
"use client";
|
||||
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Academy, Course } from "@/types/types";
|
||||
import Container from "@/components/elements/Container";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface Tag {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface PaginationType {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
hasNextPage: boolean;
|
||||
hasPrevPage: boolean;
|
||||
}
|
||||
function AcademyPage() {
|
||||
const params = useParams();
|
||||
const academyId = params?.academyId as string;
|
||||
|
||||
const { request } = useAxios();
|
||||
const [academy, setAcademy] = useState<Academy | null>(null);
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingCourses, setIsLoadingCourses] = useState(false);
|
||||
const [pagination, setPagination] = useState<PaginationType>({
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
totalItems: 0,
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false,
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
// دریافت اطلاعات آکادمی
|
||||
useEffect(() => {
|
||||
const fetchAcademy = async () => {
|
||||
if (!academyId) {
|
||||
console.error("academyId یافت نشد");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await request("POST", `/academy/findAcademyById`, {
|
||||
_id: academyId
|
||||
});
|
||||
|
||||
let academyData = null;
|
||||
if (response?.academy) {
|
||||
academyData = response.academy;
|
||||
} else if (response?.data?.academy) {
|
||||
academyData = response.data.academy;
|
||||
} else {
|
||||
academyData = response;
|
||||
}
|
||||
|
||||
setAcademy(academyData);
|
||||
} catch (error) {
|
||||
console.error("خطا در دریافت اطلاعات:", error);
|
||||
toast.error("خطا در دریافت اطلاعات آکادمی");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAcademy();
|
||||
}, [academyId]);
|
||||
|
||||
// دریافت دورههای آکادمی
|
||||
const fetchCourses = async (pageNum: number = 1) => {
|
||||
if (!academyId) return;
|
||||
|
||||
setIsLoadingCourses(true);
|
||||
try {
|
||||
const response = await request(
|
||||
"GET",
|
||||
`/academy/academy/get/getAcademyCourse/${academyId}?page=${pageNum}&limit=6&status=accept`
|
||||
);
|
||||
|
||||
console.log("دورههای آکادمی:", response);
|
||||
|
||||
if (response?.success && response?.data?.courses) {
|
||||
setCourses(response.data.courses);
|
||||
if (response.data.pagination) {
|
||||
setPagination({
|
||||
currentPage: response.data.pagination.currentPage,
|
||||
totalPages: response.data.pagination.totalPages,
|
||||
totalItems: response.data.pagination.totalItems,
|
||||
hasNextPage: response.data.pagination.hasNextPage,
|
||||
hasPrevPage: response.data.pagination.hasPrevPage,
|
||||
});
|
||||
}
|
||||
} else if (response?.data?.courses) {
|
||||
setCourses(response.data.courses);
|
||||
} else {
|
||||
setCourses([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("خطا در دریافت دورهها:", err);
|
||||
toast.error("خطا در دریافت دورههای آکادمی");
|
||||
setCourses([]);
|
||||
} finally {
|
||||
setIsLoadingCourses(false);
|
||||
}
|
||||
};
|
||||
|
||||
// بارگذاری اولیه دورهها بعد از دریافت آکادمی
|
||||
useEffect(() => {
|
||||
if (academyId) {
|
||||
fetchCourses(1);
|
||||
}
|
||||
}, [academyId]);
|
||||
|
||||
// تغییر صفحه
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage >= 1 && newPage <= pagination.totalPages) {
|
||||
fetchCourses(newPage);
|
||||
window.scrollTo({ top: 600, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
// پردازش تگها
|
||||
const parseTags = (tagString: string): Tag[] => {
|
||||
if (!tagString) return [];
|
||||
try {
|
||||
return JSON.parse(tagString);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const tags = parseTags(academy?.tag as string || "[]");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="flex justify-center items-center min-h-[60vh]">
|
||||
<div className="relative w-24 h-24">
|
||||
<div className="absolute inset-0 rounded-full border-4 border-gray-200 dark:border-gray-700"></div>
|
||||
<div className="absolute inset-0 rounded-full border-4 border-t-pink-500 border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
|
||||
</div>
|
||||
<p className="mr-4 text-gray-600 dark:text-gray-400">در حال بارگذاری...</p>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (!academy && !isLoading) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="py-20 text-center">
|
||||
<div className="text-6xl mb-4">🏫</div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-2">
|
||||
آکادمی یافت نشد
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
متأسفیم، آکادمی مورد نظر شما وجود ندارد یا حذف شده است.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="mt-6 px-6 py-2 bg-pink-500 text-white rounded-lg hover:bg-pink-600 transition-colors"
|
||||
>
|
||||
بازگشت به صفحه اصلی
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const academyName = academy?.academy_name || "آکادمی";
|
||||
const academyImage = academy?.academy_image
|
||||
? `${IMAGE_BASE_URL}${academy.academy_image}`
|
||||
: "/images/default-academy.jpg";
|
||||
const academyBio = academy?.bio || "این آکادمی هنوز توضیحاتی اضافه نکرده است.";
|
||||
const academyRate = academy?.rate || 0;
|
||||
const numberOfRate = academy?.number_of_rate || 0;
|
||||
const totalCourses = pagination.totalItems;
|
||||
const createdAt = academy?.createdAt ? new Date(academy.createdAt).toLocaleDateString("fa-IR") : "نامشخص";
|
||||
|
||||
|
||||
return (
|
||||
<Container >
|
||||
{/* هدر حرفهای کامل با تمام اطلاعات */}
|
||||
<div className="relative mb-8 ">
|
||||
{/* Background Cover with Gradient */}
|
||||
<div className="absolute inset-0 rounded-2xl overflow-hidden">
|
||||
<div className="absolute inset-0 "></div>
|
||||
<div className="absolute inset-0 bg-[url('/images/pattern.png')] opacity-10"></div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative p-6 md:p-8">
|
||||
<div className="flex flex-col md:flex-row items-center gap-6 md:gap-8">
|
||||
{/* لوگو آکادمی - سمت راست در دسکتاپ */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<div className="relative w-28 h-28 md:w-32 md:h-32 rounded-2xl overflow-hidden shadow-2xl ">
|
||||
<Image
|
||||
src={academyImage}
|
||||
alt={academyName}
|
||||
fill
|
||||
className="object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src =
|
||||
"/images/default-academy.jpg";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* اطلاعات آکادمی - سمت چپ در دسکتاپ */}
|
||||
<motion.div
|
||||
initial={{ x: -20, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="flex-1 text-center md:text-right"
|
||||
>
|
||||
<h1 className="text-2xl md:text-3xl lg:text-4xl font-bold text-white mb-2">
|
||||
{academyName}
|
||||
</h1>
|
||||
|
||||
{/* توضیحات آکادمی */}
|
||||
<p className="text-white/90 leading-relaxed text-sm md:text-base mb-3 max-w-2xl">
|
||||
{academyBio}
|
||||
</p>
|
||||
|
||||
{/* تگها */}
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center md:justify-start gap-2">
|
||||
{tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-0.5 bg-white/20 backdrop-blur-sm rounded-full text-xs text-white"
|
||||
>
|
||||
#{tag.value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* کارتهای آمار جزئی */}
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="grid p-3 grid-cols-2 md:grid-cols-4 gap-4 mb-8 -mt-6 relative z-10"
|
||||
>
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-xl p-4 shadow-lg text-center hover:shadow-xl transition-shadow">
|
||||
<div className="text-3xl mb-2">📚</div>
|
||||
<div className="text-2xl font-bold text-gray-800 dark:text-gray-100">
|
||||
{totalCourses}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">دوره آموزشی</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-xl p-4 shadow-lg text-center hover:shadow-xl transition-shadow">
|
||||
<div className="text-3xl mb-2">📅</div>
|
||||
<div className="text-2xl font-bold text-gray-800 dark:text-gray-100">
|
||||
{createdAt || "جدید"}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">تاسیس</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* لیست دورهها */}
|
||||
<motion.section
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.3 }}
|
||||
className="p-3"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-100">
|
||||
📚 دورههای آموزشی
|
||||
</h2>
|
||||
<span className="text-sm text-gray-500">{totalCourses} دوره</span>
|
||||
</div>
|
||||
|
||||
{courses.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 dark:bg-gray-800/50 rounded-2xl">
|
||||
<div className="text-6xl mb-4">📚</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
هنوز دورهای برای این آکادمی ثبت نشده است.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{courses.map((course, index) => (
|
||||
<motion.div
|
||||
key={course._id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<Link
|
||||
href={`/academy/${course._id}/course`}
|
||||
className="group block bg-white dark:bg-neutral-800 rounded-xl overflow-hidden shadow-md hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative h-48 w-full overflow-hidden">
|
||||
<Image
|
||||
src={
|
||||
course.course_image
|
||||
? `${IMAGE_BASE_URL}${course.course_image}`
|
||||
: "/images/default-course.jpg"
|
||||
}
|
||||
alt={course.cuorse_name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
{course.offer && parseInt(course.offer) > 0 && (
|
||||
<div className="absolute top-2 right-2 bg-red-500 text-white text-xs font-bold px-2 py-1 rounded-full z-10">
|
||||
🔥 {course.offer}% تخفیف
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<h3 className="font-bold text-lg text-gray-800 dark:text-gray-100 mb-1 line-clamp-1">
|
||||
{course.cuorse_name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 line-clamp-1">
|
||||
{course.caption}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-between items-center mt-3 pt-3 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
<span>👨🏫</span>
|
||||
<span>{course.teacher_name}</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-pink-500">
|
||||
{course.offer && parseInt(course.offer) > 0 ? (
|
||||
<>
|
||||
<span className="line-through text-xs text-gray-400 ml-1">
|
||||
{parseInt(course.price).toLocaleString()}
|
||||
</span>
|
||||
{(
|
||||
(parseInt(course.price) *
|
||||
(100 - parseInt(course.offer))) /
|
||||
100
|
||||
).toLocaleString()}
|
||||
</>
|
||||
) : (
|
||||
parseInt(course.price).toLocaleString()
|
||||
)}
|
||||
<span className="text-xs"> تومان</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.section>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default AcademyPage;
|
||||
12
src/app/(projects)/new-project/layout.tsx
Normal file
12
src/app/(projects)/new-project/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<TabNavigation currentPage="/new-project" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
18
src/app/(projects)/new-project/page.tsx
Normal file
18
src/app/(projects)/new-project/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import MultiStepForm from "@/components/projects/NewProject/MultiStepForm";
|
||||
import { ProjectFormProvider } from "@/contexts/ProjectFormContext";
|
||||
import React from "react";
|
||||
|
||||
function NewProject() {
|
||||
return (
|
||||
<ProjectFormProvider>
|
||||
<Container>
|
||||
<MultiStepForm />
|
||||
</Container>
|
||||
</ProjectFormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewProject;
|
||||
166
src/app/[[...slug]]/page.tsx
Normal file
166
src/app/[[...slug]]/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import Container from "@/components/elements/Container";
|
||||
import ModelsFilter from "@/components/models/ModelsFilter";
|
||||
import { cookies } from "next/headers";
|
||||
import InfinitePosts from "@/components/models/InfinitePosts";
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import { Metadata } from "next";
|
||||
|
||||
// درونریزی فایلهای دیتا برای استخراج نام شهر و استان
|
||||
import provinces from "@/data/provinces.json";
|
||||
import cities from "@/data/cities.json";
|
||||
|
||||
interface IModelsProps {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
searchParams: Promise<{
|
||||
expertise?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
userLevel?: string;
|
||||
rateFilter?: string;
|
||||
hashtag?: string; // این خط را اضافه کنید
|
||||
}>;
|
||||
}
|
||||
|
||||
// تابع هوشمند تولید متادیتا
|
||||
export async function generateMetadata({ params, searchParams }: IModelsProps): Promise<Metadata> {
|
||||
const [urlParams, filters] = await Promise.all([params, searchParams]);
|
||||
let locationLabel = "";
|
||||
let levelLabel = "";
|
||||
|
||||
try {
|
||||
if (urlParams.slug && urlParams.slug.length > 0) {
|
||||
const decodedText = decodeURIComponent(urlParams.slug[0]).replace(/-/g, ' ');
|
||||
|
||||
// اولویت با متن فارسی ساخته شده در URL
|
||||
if (decodedText.startsWith("استخدام")) {
|
||||
return {
|
||||
title: `${decodedText} | مدستاگرام`,
|
||||
description: `لیست برترین متخصصین مد و زیبایی: ${decodedText}`,
|
||||
alternates: { canonical: "https://modstagram.com" }
|
||||
};
|
||||
}
|
||||
|
||||
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} | مدستاگرام`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description: `پلتفرم تخصصی استخدام ${expertiseLabel}. بهترین متخصصین را در مدستاگرام پیدا کنید.`,
|
||||
alternates: { canonical: "https://modstagram.com" },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Models({ params, searchParams }: IModelsProps) {
|
||||
const [urlParams, filters, cookieStore] = await Promise.all([
|
||||
params,
|
||||
searchParams,
|
||||
cookies()
|
||||
]);
|
||||
|
||||
const token = cookieStore.get("token")?.value || "";
|
||||
|
||||
// ۱. مقادیر پایه (اولویت با کوئری پارامتر برای دقت در فیلتر)
|
||||
let provinceId = filters.province || "";
|
||||
let cityId = filters.city || "";
|
||||
let userLevel = filters.userLevel || "";
|
||||
let expertise = filters.expertise || "";
|
||||
|
||||
// ۲. استخراج و همگامسازی اطلاعات از URL فارسی (Slug)
|
||||
if (urlParams.slug && urlParams.slug.length > 0) {
|
||||
const fullText = decodeURIComponent(urlParams.slug[0]).replace(/-/g, ' ');
|
||||
|
||||
// استخراج تخصص (فقط اگر در متن موجود باشد)
|
||||
if (fullText.includes("مدل")) expertise = "مدل";
|
||||
else if (fullText.includes("عکاس")) expertise = "عکاس";
|
||||
else if (fullText.includes("آرایشگر")) expertise = "آرایشگر";
|
||||
else if (fullText.includes("متخصصین")) expertise = ""; // حالت بدون تخصص
|
||||
|
||||
// استخراج سطح
|
||||
if (fullText.includes("تازه وارد")) userLevel = "تازه وارد";
|
||||
else if (fullText.includes("استاندارد")) userLevel = "استاندارد";
|
||||
else if (fullText.includes("حرفه ای")) userLevel = "حرفه ای";
|
||||
else if (fullText.includes("استاد")) userLevel = "استاد";
|
||||
|
||||
// استخراج مکان (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}` : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Container>
|
||||
{/* H1 مخفی برای بهبود سئو بر اساس آدرس صفحه */}
|
||||
<h1 className="sr-only">استخدام {displayExpertise}{levelText} در مدستاگرام</h1>
|
||||
|
||||
{/* کامپوننت فیلتر با مقدار تخصص فعلی */}
|
||||
<ModelsFilter expertise={expertise || filters.expertise || ""} />
|
||||
|
||||
{/* نمایش پستها با تمام فیلترهای استخراج شده */}
|
||||
<InfinitePosts
|
||||
filters={{
|
||||
...filters,
|
||||
hashtag: filters.hashtag, // این خط را اضافه کن
|
||||
province: provinceId,
|
||||
city: cityId,
|
||||
userLevel: userLevel || filters.userLevel,
|
||||
expertise: expertise || filters.expertise
|
||||
}}
|
||||
token={token}
|
||||
// مقدار typeFilter را حذف کنید تا همه انواع نمایش داده شود
|
||||
/>
|
||||
</Container>
|
||||
<TabNavigation currentPage="/" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
src/app/about-us/layout.tsx
Normal file
12
src/app/about-us/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import Footer from "@/components/main/Footer";
|
||||
import Header from "@/components/main/Header";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<Footer />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
40
src/app/about-us/page.tsx
Normal file
40
src/app/about-us/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
function AboutUs() {
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default AboutUs;
|
||||
131
src/app/billboards/[[...slug]]/page.tsx
Normal file
131
src/app/billboards/[[...slug]]/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import Container from "@/components/elements/Container";
|
||||
import { cookies } from "next/headers";
|
||||
import InfiniteBillboard from "@/components/billboards/InfiniteBillboard";
|
||||
import BillboardsFilter from "@/components/billboards/BillboardPage/BillboardsFilter";
|
||||
import { fetchBillboards } from "@/api/fetchBillboards";
|
||||
import { Metadata } from "next";
|
||||
|
||||
// درونریزی دیتاها برای تبدیل متن به ID
|
||||
import provinces from "@/data/provinces.json";
|
||||
import cities from "@/data/cities.json";
|
||||
|
||||
interface IBillboardsProps {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
searchParams: Promise<{
|
||||
province?: string;
|
||||
city?: string;
|
||||
category?: string;
|
||||
search?: string;
|
||||
sort?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// تابع استخراج داده که هم در 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 [urlParams, filters] = await Promise.all([params, searchParams]);
|
||||
const { locationLabel, categoryLabel } = getSEOData(urlParams.slug, filters);
|
||||
|
||||
const title = `تبلیغات و بیلبورد ${categoryLabel}${locationLabel} | مدستاگرام`;
|
||||
const description = `بهترین ${categoryLabel}${locationLabel}. لیست مراکز معتبر و بیلبوردهای تبلیغاتی در پلتفرم تخصصی مدستاگرام.`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: "https://modstagram.com/billboards" },
|
||||
openGraph: { title, description, url: "https://modstagram.com/billboards", siteName: "مدستاگرام", locale: "fa_IR", type: "website" }
|
||||
};
|
||||
}
|
||||
|
||||
// --- اصلاح فیلتر و محتوا ---
|
||||
export default async function Billboards({ params, searchParams }: IBillboardsProps) {
|
||||
const [urlParams, filters, cookieStore] = await Promise.all([
|
||||
params,
|
||||
searchParams,
|
||||
cookies()
|
||||
]);
|
||||
|
||||
const token = cookieStore.get("token")?.value || "";
|
||||
|
||||
// ۱. استخراج دادههای واقعی از اسلاگ فارسی
|
||||
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
|
||||
};
|
||||
|
||||
// ۳. ارسال فیلترهای صحیح به API
|
||||
const initialData = await fetchBillboards(1, 10, combinedFilters, token);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h1 className="sr-only">
|
||||
تبلیغات {categoryLabel}{locationLabel} در مدستاگرام
|
||||
</h1>
|
||||
|
||||
<BillboardsFilter />
|
||||
|
||||
{/* ۴. پاس دادن فیلترهای ترکیبی به کامپوننت اینفینیت */}
|
||||
<InfiniteBillboard
|
||||
initialData={initialData}
|
||||
filters={combinedFilters}
|
||||
token={token}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
201
src/app/billboards/[id]/[title]/page.tsx
Normal file
201
src/app/billboards/[id]/[title]/page.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import React from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import Container from "@/components/elements/Container";
|
||||
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 { Metadata } from "next";
|
||||
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import SEO from "@/config/SEO";
|
||||
|
||||
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",
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: IBillboardProps): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const token = (await cookies()).get("token")?.value || "";
|
||||
|
||||
const data = await getBillboardData(id, token);
|
||||
const billboard = data?.advertising as IAdvertising;
|
||||
|
||||
if (!billboard) {
|
||||
return { title: "بیلبورد یافت نشد | مدستاگرام" };
|
||||
}
|
||||
|
||||
// ۱. استخراج متغیرها
|
||||
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 = [
|
||||
title,
|
||||
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, '-'); // جلوگیری از تکرار خط تیره
|
||||
|
||||
// ۴. تنظیم دیسکریپشن طبق فرمت: آدرس - توضیحات
|
||||
const address = billboard.address || `${province} ${city} ${neighborhood}`;
|
||||
const dynamicDescription = `${address} - ${billboard.description || ""}`.substring(0, 160);
|
||||
|
||||
// کلمه "مدستاگرام" توسط تابع generatePageMetadata به انتهای تایتل اضافه میشود
|
||||
return generatePageMetadata({
|
||||
title: dynamicTitle,
|
||||
description: dynamicDescription,
|
||||
path: `/billboards/${id}/${encodeURIComponent(urlSlug)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function BillboardPage({ params }: IBillboardProps) {
|
||||
const { id } = await params;
|
||||
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>;
|
||||
}
|
||||
|
||||
const billboard = data.advertising as IAdvertising;
|
||||
const rate = data.rate as IRate;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<SEO type="store" data={billboard} />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default BillboardPage;
|
||||
12
src/app/billboards/layout.tsx
Normal file
12
src/app/billboards/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<TabNavigation currentPage="/billboards" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
115
src/app/billboards/new/[id]/page.tsx
Normal file
115
src/app/billboards/new/[id]/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import { IAdvertising, IAdvertisingType } from "@/types/types";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
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 { useRouter } from "next/navigation";
|
||||
|
||||
interface IBillboardProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
function BillboardPayment({ params }: IBillboardProps) {
|
||||
const resolvedParams = React.use(params);
|
||||
const router = useRouter();
|
||||
const { request } = useAxios();
|
||||
const { id } = resolvedParams;
|
||||
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 }>(
|
||||
"GET",
|
||||
`/advertising/get/web/${id}`
|
||||
);
|
||||
setAdvertising(response?.advertising);
|
||||
};
|
||||
const fetchStates = async () => {
|
||||
try {
|
||||
const response = await request<{
|
||||
advertisingTypes: IAdvertisingType[];
|
||||
}>("GET", "/advertising/types");
|
||||
setTypeList(response?.advertisingTypes || null);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
fetchAd();
|
||||
fetchStates();
|
||||
}, []);
|
||||
|
||||
const payHandler = async () => {
|
||||
try {
|
||||
const response = await request<{ authority?: string }>(
|
||||
"POST",
|
||||
"/advertising/initiate-payment-web",
|
||||
{
|
||||
item_name: advertising?.type, // Pass advertisingId to initiate payment
|
||||
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
|
||||
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 عددی است و نیاز به تبدیل به رشته دارد
|
||||
}
|
||||
}
|
||||
}, [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
|
||||
onClick={payHandler}
|
||||
className="w-32 h-9 !border-[#0C8002] text-[#0C8002]"
|
||||
>
|
||||
پرداخت
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default BillboardPayment;
|
||||
16
src/app/billboards/new/page.tsx
Normal file
16
src/app/billboards/new/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import MultiStepForm from "@/components/billboards/NewBillboard/MultiStepForm";
|
||||
import Container from "@/components/elements/Container";
|
||||
import { BillboardFormProvider } from "@/contexts/BillboardFormContext";
|
||||
import React from "react";
|
||||
|
||||
function NewBillboard() {
|
||||
return (
|
||||
<BillboardFormProvider>
|
||||
<Container>
|
||||
<MultiStepForm />
|
||||
</Container>
|
||||
</BillboardFormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewBillboard;
|
||||
150
src/app/billboards/payment/failed/page.tsx
Normal file
150
src/app/billboards/payment/failed/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import { IAdvertising, IAdvertisingType } from "@/types/types";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
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 { useRouter, useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
|
||||
function FailedBillboard() {
|
||||
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 }>(
|
||||
"GET",
|
||||
`/advertising/get/web/${projectId}`
|
||||
);
|
||||
setAdvertising(response?.advertising);
|
||||
};
|
||||
const fetchStates = async () => {
|
||||
try {
|
||||
const response = await request<{
|
||||
advertisingTypes: IAdvertisingType[];
|
||||
}>("GET", "/advertising/types");
|
||||
setTypeList(response?.advertisingTypes || null);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
fetchAd();
|
||||
fetchStates();
|
||||
}, []);
|
||||
|
||||
const payHandler = async () => {
|
||||
try {
|
||||
const response = await request<{ authority?: string }>(
|
||||
"POST",
|
||||
"/advertising/initiate-payment-web",
|
||||
{
|
||||
item_name: advertising?.type, // Pass advertisingId to initiate payment
|
||||
advertisingId: projectId,
|
||||
showDiscount: advertising?.showDiscount,
|
||||
}
|
||||
);
|
||||
|
||||
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
|
||||
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
|
||||
advertisingId: projectId,
|
||||
showDiscount: advertising?.showDiscount,
|
||||
}
|
||||
);
|
||||
|
||||
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
|
||||
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 عددی است و نیاز به تبدیل به رشته دارد
|
||||
}
|
||||
}
|
||||
}, [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();
|
||||
}
|
||||
}}
|
||||
className="w-32 h-9 !border-[#0C8002] text-[#0C8002]"
|
||||
>
|
||||
پرداخت مجدد
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default FailedBillboard;
|
||||
94
src/app/billboards/payment/success/page.tsx
Normal file
94
src/app/billboards/payment/success/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import { IAdvertising, IAdvertisingType } from "@/types/types";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
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 { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
function SuccessBillboard() {
|
||||
const { request } = useAxios();
|
||||
const searchParams = useSearchParams();
|
||||
const projectId = searchParams.get("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 }>(
|
||||
"GET",
|
||||
`/advertising/get/web/${projectId}`
|
||||
);
|
||||
setAdvertising(response?.advertising);
|
||||
};
|
||||
const fetchStates = async () => {
|
||||
try {
|
||||
const response = await request<{
|
||||
advertisingTypes: IAdvertisingType[];
|
||||
}>("GET", "/advertising/types");
|
||||
setTypeList(response?.advertisingTypes || null);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
fetchAd();
|
||||
fetchStates();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (advertising?.type && typeList) {
|
||||
const foundType = typeList.find((item) => item.name === advertising.type);
|
||||
if (foundType) {
|
||||
setPrice(String(foundType.price)); // فرض بر اینکه 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 className="w-32 h-9 !border-[#0C8002] text-[#0C8002]">
|
||||
بیلورد من
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SuccessBillboard;
|
||||
89
src/app/billboards/profile/[...id]/page.tsx
Normal file
89
src/app/billboards/profile/[...id]/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import React, { cache } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
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";
|
||||
|
||||
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'
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.profileDetails as IAdvertisingProfile;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
export async function generateMetadata({ params }: IUserProps): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const profile = await getProfile(id);
|
||||
|
||||
if (!profile) {
|
||||
return { title: "آگهی یافت نشد | مدستاگرام" };
|
||||
}
|
||||
|
||||
// --- تنظیم تایتل طبق فرمت درخواستی شما ---
|
||||
// فرمت: عنوان آگهی - دسته بندی - محله - شهر - مدستاگرام
|
||||
const titleParts = [
|
||||
profile.title,
|
||||
profile.category?.name,
|
||||
profile.neighborhood?.name,
|
||||
profile.city?.name,
|
||||
"مدستاگرام"
|
||||
].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";
|
||||
|
||||
return {
|
||||
title: finalTitle,
|
||||
description: description,
|
||||
openGraph: {
|
||||
title: finalTitle,
|
||||
description: description,
|
||||
url: `https://modstagram.com/billboards/${id}`,
|
||||
siteName: "مدستاگرام",
|
||||
images: [{ url: image }],
|
||||
locale: "fa_IR",
|
||||
type: "article",
|
||||
},
|
||||
alternates: {
|
||||
canonical: `https://modstagram.com/billboards/${id}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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>;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
89
src/app/billboards/profile/[id]/[title]/page.tsx
Normal file
89
src/app/billboards/profile/[id]/[title]/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import React, { cache } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
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";
|
||||
|
||||
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'
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.profileDetails as IAdvertisingProfile;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
export async function generateMetadata({ params }: IUserProps): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const profile = await getProfile(id);
|
||||
|
||||
if (!profile) {
|
||||
return { title: "آگهی یافت نشد | مدستاگرام" };
|
||||
}
|
||||
|
||||
// --- تنظیم تایتل طبق فرمت درخواستی شما ---
|
||||
// فرمت: عنوان آگهی - دسته بندی - محله - شهر - مدستاگرام
|
||||
const titleParts = [
|
||||
profile.title,
|
||||
profile.category?.name,
|
||||
profile.neighborhood?.name,
|
||||
profile.city?.name,
|
||||
"مدستاگرام"
|
||||
].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";
|
||||
|
||||
return {
|
||||
title: finalTitle,
|
||||
description: description,
|
||||
openGraph: {
|
||||
title: finalTitle,
|
||||
description: description,
|
||||
url: `https://modstagram.com/billboards/${id}`,
|
||||
siteName: "مدستاگرام",
|
||||
images: [{ url: image }],
|
||||
locale: "fa_IR",
|
||||
type: "article",
|
||||
},
|
||||
alternates: {
|
||||
canonical: `https://modstagram.com/billboards/${id}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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>;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
21
src/app/explore/[id]/page.tsx
Normal file
21
src/app/explore/[id]/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import PostFeedView from "@/components/posts/PostFeedView";
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
|
||||
export default function ExploreReelPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return (
|
||||
<Container className="!px-0">
|
||||
<Suspense fallback={<PageLoader className="min-h-[100dvh]" />}>
|
||||
<PostFeedView initialPostId={id} videoOnly showClose />
|
||||
</Suspense>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
28
src/app/explore/page.tsx
Normal file
28
src/app/explore/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import ExploreGrid from "@/components/explore/ExploreGrid";
|
||||
import Link from "next/link";
|
||||
import { FiChevronRight } from "react-icons/fi";
|
||||
|
||||
export default function ExplorePage() {
|
||||
return (
|
||||
<>
|
||||
<Container className="pb-28">
|
||||
<header className="glass-panel sticky top-0 z-40 mb-2 flex items-center gap-2 border-b border-white/30 px-3 py-3 dark:border-white/10">
|
||||
<Link
|
||||
href="/"
|
||||
className="gentle-transition flex h-9 w-9 items-center justify-center rounded-full text-[#0095f6] active:scale-90"
|
||||
aria-label="بازگشت"
|
||||
>
|
||||
<FiChevronRight size={22} />
|
||||
</Link>
|
||||
<h1 className="text-lg font-bold">اکسپلور</h1>
|
||||
</header>
|
||||
<ExploreGrid />
|
||||
</Container>
|
||||
<TabNavigation currentPage="/explore" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
508
src/app/globals.css
Normal file
508
src/app/globals.css
Normal file
@@ -0,0 +1,508 @@
|
||||
@import "tailwindcss";
|
||||
@import "@radix-ui/themes/styles.css";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--breakpoint-desktop-3xl : 1920px;
|
||||
--breakpoint-offer-wrap : 1890px;
|
||||
--breakpoint-desktop-2-3xl : 1664px;
|
||||
--breakpoint-desktop-2xl : 1610px;
|
||||
--breakpoint-desktop-xl : 1450px;
|
||||
--breakpoint-desktop-l : 1376px;
|
||||
--breakpoint-desktop-s : 1290px;
|
||||
--breakpoint-desktop-xs : 1211px;
|
||||
--breakpoint-desktop-xsx : 1110px;
|
||||
--breakpoint-laptop-xl : 1034px;
|
||||
--breakpoint-laptop-sl : 969px;
|
||||
--breakpoint-laptop-l : 965px;
|
||||
--breakpoint-laptop-s : 911px;
|
||||
--breakpoint-tablet-xl : 810px;
|
||||
--breakpoint-tablet-lg : 797px;
|
||||
--breakpoint-tablet-l : 774px;
|
||||
--breakpoint-tablet-xs : 734px;
|
||||
--breakpoint-Wide-mobile-4xl : 645px;
|
||||
--breakpoint-Wide-mobile-4-5xl : 626px;
|
||||
--breakpoint-Wide-mobile-3xl : 605px;
|
||||
--breakpoint-Wide-mobile-2xl : 573px;
|
||||
--breakpoint-Wide-mobile-xl : 545px ;
|
||||
--breakpoint-Wide-mobile-l : 539px;
|
||||
--breakpoint-Wide-mobile-xss : 526px;
|
||||
--breakpoint-Wide-mobile-s : 485px;
|
||||
--breakpoint-Wide-mobile-xsss : 466px;
|
||||
--breakpoint-Wide-mobile-sm : 430px;
|
||||
--breakpoint-Wide-mobile-xs : 400px;
|
||||
--breakpoint-mobile-xl : 365px;
|
||||
--breakpoint-mobile-xlk : 381px;
|
||||
--breakpoint-mobile-l : 335px;
|
||||
--breakpoint-mobile-s : 245px;
|
||||
|
||||
|
||||
}
|
||||
/* مخفی کردن اسکرولبار در همه مرورگرها */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none !important; /* IE و Edge */
|
||||
scrollbar-width: none !important; /* Firefox */
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none !important; /* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
/* globals.css */
|
||||
.input-no-spinner {
|
||||
-moz-appearance: textfield; /* Firefox */
|
||||
appearance: none; /* تمامی مرورگرها */
|
||||
}
|
||||
|
||||
.input-no-spinner::-webkit-outer-spin-button,
|
||||
.input-no-spinner::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm : calc(var(--radius) - 4px);
|
||||
--radius-md : calc(var(--radius) - 2px);
|
||||
--radius-lg : var(--radius);
|
||||
--radius-xl : calc(var(--radius) + 4px);
|
||||
--color-background : var(--background);
|
||||
--color-foreground : var(--foreground);
|
||||
--color-card : var(--card);
|
||||
--color-card-foreground : var(--card-foreground);
|
||||
--color-popover : var(--popover);
|
||||
--color-popover-foreground : var(--popover-foreground);
|
||||
--color-primary : var(--primary);
|
||||
--color-primary-foreground : var(--primary-foreground);
|
||||
--color-secondary : var(--secondary);
|
||||
--color-secondary-foreground : var(--secondary-foreground);
|
||||
--color-muted : var(--muted);
|
||||
--color-muted-foreground : var(--muted-foreground);
|
||||
--color-accent : var(--accent);
|
||||
--color-accent-foreground : var(--accent-foreground);
|
||||
--color-destructive : var(--destructive);
|
||||
--color-border : var(--border);
|
||||
--color-input : var(--input);
|
||||
--color-ring : var(--ring);
|
||||
--color-chart-1 : var(--chart-1);
|
||||
--color-chart-2 : var(--chart-2);
|
||||
--color-chart-3 : var(--chart-3);
|
||||
--color-chart-4 : var(--chart-4);
|
||||
--color-chart-5 : var(--chart-5);
|
||||
--color-sidebar : var(--sidebar);
|
||||
--color-sidebar-foreground : var(--sidebar-foreground);
|
||||
--color-sidebar-primary : var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground : var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent : var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground : var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border : var(--sidebar-border);
|
||||
--color-sidebar-ring : var(--sidebar-ring);}
|
||||
|
||||
:root {
|
||||
--input: oklch(0 0 0 / 100%); /* مشکی کاملا */
|
||||
--border: oklch(0 0 0 / 50%); /* خاکستری */
|
||||
--ring: oklch(0 0 0 / 70%);
|
||||
--radius : 0.625rem;
|
||||
--background : oklch(1 0 0);
|
||||
--foreground : oklch(0.145 0 0);
|
||||
--card : oklch(1 0 0);
|
||||
--card-foreground : oklch(0.145 0 0);
|
||||
--popover : oklch(1 0 0);
|
||||
--popover-foreground : oklch(0.145 0 0);
|
||||
--primary : oklch(0.205 0 0);
|
||||
--primary-foreground : oklch(0.985 0 0);
|
||||
--secondary : oklch(0.97 0 0);
|
||||
--secondary-foreground : oklch(0.205 0 0);
|
||||
--muted : oklch(0.97 0 0);
|
||||
--muted-foreground : oklch(0.556 0 0);
|
||||
--accent : oklch(0.97 0 0);
|
||||
--accent-foreground : oklch(0.205 0 0);
|
||||
--destructive : oklch(0.577 0.245 27.325);
|
||||
--border : oklch(0.922 0 0);
|
||||
--input : oklch(0.922 0 0);
|
||||
--ring : oklch(0.708 0 0);
|
||||
--chart-1 : oklch(0.646 0.222 41.116);
|
||||
--chart-2 : oklch(0.6 0.118 184.704);
|
||||
--chart-3 : oklch(0.398 0.07 227.392);
|
||||
--chart-4 : oklch(0.828 0.189 84.429);
|
||||
--chart-5 : oklch(0.769 0.188 70.08);
|
||||
--sidebar : oklch(0.985 0 0);
|
||||
--sidebar-foreground : oklch(0.145 0 0);
|
||||
--sidebar-primary : oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground : oklch(0.985 0 0);
|
||||
--sidebar-accent : oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground : oklch(0.205 0 0);
|
||||
--sidebar-border : oklch(0.922 0 0);
|
||||
--sidebar-ring : oklch(0.708 0 0);}
|
||||
|
||||
.dark {
|
||||
--input: oklch(1 0 0 / 100%); /* سفید کامل */
|
||||
--border: oklch(1 0 0 / 50%);
|
||||
--ring: oklch(1 0 0 / 70%);
|
||||
--background : oklch(0.145 0 0);
|
||||
--foreground : oklch(0.985 0 0);
|
||||
--card : oklch(0.205 0 0);
|
||||
--card-foreground : oklch(0.985 0 0);
|
||||
--popover : oklch(0.205 0 0);
|
||||
--popover-foreground : oklch(0.985 0 0);
|
||||
--primary : oklch(0.922 0 0);
|
||||
--primary-foreground : oklch(0.205 0 0);
|
||||
--secondary : oklch(0.269 0 0);
|
||||
--secondary-foreground : oklch(0.985 0 0);
|
||||
--muted : oklch(0.269 0 0);
|
||||
--muted-foreground : oklch(0.708 0 0);
|
||||
--accent : oklch(0.269 0 0);
|
||||
--accent-foreground : oklch(0.985 0 0);
|
||||
--destructive : oklch(0.704 0.191 22.216);
|
||||
--border : oklch(1 0 0 / 10%);
|
||||
--input : oklch(1 0 0 / 15%);
|
||||
--ring : oklch(0.556 0 0);
|
||||
--chart-1 : oklch(0.488 0.243 264.376);
|
||||
--chart-2 : oklch(0.696 0.17 162.48);
|
||||
--chart-3 : oklch(0.769 0.188 70.08);
|
||||
--chart-4 : oklch(0.627 0.265 303.9);
|
||||
--chart-5 : oklch(0.645 0.246 16.439);
|
||||
--sidebar : oklch(0.205 0 0);
|
||||
--sidebar-foreground : oklch(0.985 0 0);
|
||||
--sidebar-primary : oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground : oklch(0.985 0 0);
|
||||
--sidebar-accent : oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground : oklch(0.985 0 0);
|
||||
--sidebar-border : oklch(1 0 0 / 10%);
|
||||
--sidebar-ring : oklch(0.556 0 0);}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;}
|
||||
body {
|
||||
@apply bg-background text-foreground;}}
|
||||
|
||||
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
body {
|
||||
direction: rtl;
|
||||
}
|
||||
button,
|
||||
button:focus,
|
||||
textarea {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
button:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
}
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
/* Firefox */
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
select {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-image: url("/images/icons/arrow-down.svg");
|
||||
background-size: 15px;
|
||||
background-repeat: no-repeat;
|
||||
background-position-x: 10px;
|
||||
background-position-y: 13px;
|
||||
}
|
||||
|
||||
.dir-ltr {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
max-width: 992px;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-page .rmdp-input {
|
||||
border-radius: 1rem;
|
||||
margin-top: 8px;
|
||||
height: 36px;
|
||||
color: #111;
|
||||
width: 100%;
|
||||
padding-right: 48px;
|
||||
}
|
||||
.address-page .mapboxgl-map {
|
||||
max-height: 210px;
|
||||
height: 100%;
|
||||
width: 100% !important;
|
||||
max-width: 300px;
|
||||
border-radius: 12px !important;
|
||||
overflow: hidden !important;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.address-page .map-g {
|
||||
width: 100%;
|
||||
border-radius: 12px !important;
|
||||
margin-top: 12px;
|
||||
}
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
/* ─── iOS Spinner & unified loading ─── */
|
||||
@keyframes ios-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-ios-spin {
|
||||
animation: ios-spin 0.9s steps(12) infinite;
|
||||
}
|
||||
|
||||
/* Legacy alias → use IOSSpinner component instead */
|
||||
.custom-loader {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: conic-gradient(from 0deg, #007aff 0deg, transparent 60deg, transparent 300deg, #007aff 360deg);
|
||||
mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 2px));
|
||||
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 2px));
|
||||
animation: ios-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
/* ─── Glassmorphism utilities ─── */
|
||||
.glass-panel {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.dark .glass-panel {
|
||||
background: rgba(28, 28, 30, 0.78);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.glass-chat-input {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(24px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(200%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08), 0 1px 0 rgba(255, 255, 255, 0.6) inset;
|
||||
}
|
||||
.dark .glass-chat-input {
|
||||
background: rgba(44, 44, 46, 0.88);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ─── Gentle transitions ─── */
|
||||
.gentle-transition {
|
||||
transition: all 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
/* ─── Chat scrollbar ─── */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* ─── Safari floating header pill ─── */
|
||||
.safari-header-pill {
|
||||
max-width: min(100% - 2rem, 22rem);
|
||||
}
|
||||
|
||||
/* ─── Instagram DM chat theme ─── */
|
||||
.chat-page-bg {
|
||||
background: #ffffff;
|
||||
}
|
||||
.dark .chat-page-bg {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
/* Outgoing — Instagram DM (RTL: tail bottom-left / outer edge) */
|
||||
.chat-bubble-out,
|
||||
.ig-dm-out {
|
||||
background: linear-gradient(135deg, #5b51d8 0%, #c13584 45%, #e1306c 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.bubble-tail-out .chat-bubble-out,
|
||||
.bubble-tail-out .chat-text-bubble.chat-bubble-out {
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
}
|
||||
/* Incoming — tail bottom-right */
|
||||
.chat-bubble-in,
|
||||
.ig-dm-in {
|
||||
background: #efefef;
|
||||
color: #000;
|
||||
}
|
||||
.bubble-tail-in .chat-bubble-in,
|
||||
.bubble-tail-in .chat-text-bubble.chat-bubble-in {
|
||||
border-radius: 18px 18px 4px 18px;
|
||||
}
|
||||
.dark .chat-bubble-in,
|
||||
.dark .ig-dm-in {
|
||||
background: #262626;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.chat-text-bubble {
|
||||
position: relative;
|
||||
padding-bottom: 1.25rem;
|
||||
min-width: 4rem;
|
||||
max-width: min(85vw, 320px);
|
||||
}
|
||||
.chat-message-text {
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.message-time-inline {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 8px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.bubble-tail-out .message-time-inline {
|
||||
left: auto;
|
||||
right: 8px;
|
||||
}
|
||||
.bubble-tail-in .message-time-inline {
|
||||
left: 8px;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.glass-panel-strong {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.dark .glass-panel-strong {
|
||||
background: rgba(30, 30, 30, 0.78);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.comments-modal-glass {
|
||||
backdrop-filter: blur(28px) saturate(1.5);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(1.5);
|
||||
}
|
||||
|
||||
.chat-action-btn {
|
||||
background: linear-gradient(135deg, #0095f6 0%, #007aff 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(0, 122, 255, 0.35);
|
||||
}
|
||||
|
||||
.header-offline {
|
||||
background: linear-gradient(180deg, rgba(220, 38, 38, 0.92) 0%, rgba(185, 28, 28, 0.88) 100%) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.header-syncing .brand-logo {
|
||||
opacity: 0;
|
||||
}
|
||||
.header-syncing-dots span {
|
||||
animation: typing-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
.header-syncing-dots span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.header-syncing-dots span:nth-child(3) { animation-delay: 0.3s; }
|
||||
|
||||
.tab-active-glass {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.dark .tab-active-glass {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
.chat-input-area--thread {
|
||||
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.ig-dm-send-btn {
|
||||
background: linear-gradient(135deg, #5b51d8 0%, #c13584 50%, #e1306c 100%);
|
||||
}
|
||||
|
||||
/* ─── Instagram-style typing dots ─── */
|
||||
@keyframes typing-bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-4px); opacity: 1; }
|
||||
}
|
||||
.typing-dot {
|
||||
animation: typing-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
.typing-dot:nth-child(2) { animation-delay: 0.15s; }
|
||||
.typing-dot:nth-child(3) { animation-delay: 0.3s; }
|
||||
|
||||
/* ─── Read receipt check animation ─── */
|
||||
@keyframes check-pop {
|
||||
0% { transform: scale(0); opacity: 0; }
|
||||
60% { transform: scale(1.15); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
.read-check-animate {
|
||||
animation: check-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
/* ─── Message enter animation ─── */
|
||||
@keyframes message-in {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
.message-enter {
|
||||
animation: message-in 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
}
|
||||
|
||||
/* ─── Shrink header ─── */
|
||||
.header-shrink {
|
||||
transform: scale(0.92) translateY(-2px);
|
||||
padding-top: 0.5rem !important;
|
||||
padding-bottom: 0.5rem !important;
|
||||
}
|
||||
/* استایل اختصاصی برای پلیر در تم روشن */
|
||||
.custom-audio-player::-webkit-media-controls-enclosure {
|
||||
background-color: #e5e7eb; /* خاکستری تیره تر برای تم روشن */
|
||||
}
|
||||
|
||||
/* برای اینکه دکمهها و نوشتههای پلیر در تم روشن تیره بمانند */
|
||||
.custom-audio-player {
|
||||
filter: contrast(1.1) brightness(0.9);
|
||||
}
|
||||
|
||||
/* در تم تاریک، پلیر را به رنگهای روشن معکوس میکنیم */
|
||||
.dark .custom-audio-player {
|
||||
filter: invert(1) hue-rotate(180deg) brightness(1.2);
|
||||
}
|
||||
|
||||
/* حذف پسزمینه سفید اضافی در برخی مرورگرها */
|
||||
.custom-audio-player::-webkit-media-controls-panel {
|
||||
background-color: transparent;
|
||||
}
|
||||
91
src/app/layout.tsx
Normal file
91
src/app/layout.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import { defaultSEOConfig } from "@/config/seoConfig";
|
||||
|
||||
import "./globals.css";
|
||||
import Layout from "@/components/Layout";
|
||||
import RegisterSW from "@/components/RegisterSW";
|
||||
|
||||
const iranSansFont = localFont({
|
||||
src: "./../../public/fonts/IRANSansX-Regular.woff",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: defaultSEOConfig.title,
|
||||
description: defaultSEOConfig.description,
|
||||
metadataBase: new URL("https://modstagram.com"),
|
||||
keywords: [
|
||||
"مادستاگرام",
|
||||
"مدلینگ",
|
||||
"بیلبورد تبلیغاتی",
|
||||
"پست مدل",
|
||||
"آموزشگاه مدلینگ",
|
||||
],
|
||||
authors: [{ name: "Modstagram", url: "https://modstagram.com" }],
|
||||
creator: "Modstagram",
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
openGraph: {
|
||||
title: defaultSEOConfig.openGraph?.title || "",
|
||||
description: defaultSEOConfig.openGraph?.description || "",
|
||||
url: defaultSEOConfig.openGraph?.url || "",
|
||||
siteName: defaultSEOConfig.openGraph?.site_name || "",
|
||||
locale: "fa_IR",
|
||||
type: "website",
|
||||
},
|
||||
robots: "index, follow",
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: defaultSEOConfig.title || "",
|
||||
description: defaultSEOConfig.description || "",
|
||||
creator: "@modstagram",
|
||||
images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
|
||||
},
|
||||
verification: {
|
||||
google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
|
||||
},
|
||||
};
|
||||
|
||||
const getInitialTheme = (): string => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("theme") || "light";
|
||||
}
|
||||
return "light";
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const initialTheme = getInitialTheme();
|
||||
|
||||
return (
|
||||
<html lang="fa" dir="rtl" className={initialTheme}>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<link rel="apple-touch-icon" href="/images/icons/192x192.png" />
|
||||
|
||||
<head>
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
href="/images/icons/apple-touch-icon.png"
|
||||
/>
|
||||
<meta name="msapplication-TileColor" content="#0072BC" />
|
||||
<meta name="msapplication-TileImage" content="/path/to/tileImage.png" />
|
||||
<meta name="samandehi" content="522190795" />
|
||||
</head>
|
||||
<body
|
||||
className={`${iranSansFont.className} antialiased bg-white dark:bg-neutral-950 text-text-primary-light dark:text-primary-light`}
|
||||
>
|
||||
<RegisterSW />
|
||||
<Layout>{children}</Layout>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
10
src/app/loading.tsx
Normal file
10
src/app/loading.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/app/new-post/layout.tsx
Normal file
12
src/app/new-post/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<TabNavigation currentPage="/new-post" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
347
src/app/new-post/page.tsx
Normal file
347
src/app/new-post/page.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import Cookies from "js-cookie";
|
||||
import { optimizeImageToWebP, optimizeVideoToMp4 } from "@/lib/media";
|
||||
import TagUsersPicker, { TaggedUser } from "@/components/posts/TagUsersPicker";
|
||||
|
||||
function NewPost() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [postType, setPostType] = useState<"image" | "video" | null>("image");
|
||||
const [postFiles, setPostFiles] = useState<(File | null)[]>([null]);
|
||||
const [previews, setPreviews] = useState<string[]>([""]);
|
||||
const [description, setDescription] = useState<string>("");
|
||||
const [taggedUsers, setTaggedUsers] = useState<TaggedUser[]>([]);
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = (error) => reject(error);
|
||||
});
|
||||
};
|
||||
|
||||
const selectFiles = (index: number) => async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
const file = event.target.files[0];
|
||||
const isValid = postType === "video" ? file.type.startsWith('video/') : file.type.startsWith('image/');
|
||||
|
||||
if (!isValid) {
|
||||
toast.error(postType === "video" ? "فقط ویدئو مجاز است" : "فقط تصویر مجاز است");
|
||||
return;
|
||||
}
|
||||
|
||||
if (postType === "video" && postFiles.length > 0) {
|
||||
toast.error("فقط یک ویدئو مجاز است");
|
||||
return;
|
||||
}
|
||||
|
||||
const newFiles = [...postFiles];
|
||||
const newPreviews = [...previews];
|
||||
newFiles[index] = file;
|
||||
newPreviews[index] = URL.createObjectURL(file);
|
||||
setPostFiles(newFiles);
|
||||
setPreviews(newPreviews);
|
||||
|
||||
if (postType === "image" && newFiles.filter((f) => f !== null).length < 10) {
|
||||
setPostFiles((prev) => [...prev, null]);
|
||||
setPreviews((prev) => [...prev, ""]);
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const uploadPost = async () => {
|
||||
const validFiles = postFiles.filter((file) => file !== null);
|
||||
if (validFiles.length === 0) {
|
||||
toast.error("انتخاب تصویر یا ویدئو الزامی است");
|
||||
return;
|
||||
}
|
||||
if (!description) {
|
||||
toast.error("وارد کردن توضیحات الزامی است");
|
||||
return;
|
||||
}
|
||||
if (postType === "video" && validFiles.length > 1) {
|
||||
toast.error("برای ویدئو فقط یک فایل مجاز است");
|
||||
return;
|
||||
}
|
||||
if (postType === "image" && validFiles.length > 10) {
|
||||
toast.error("حداکثر 10 تصویر مجاز است");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toast.loading("در حال بهینهسازی مدیا…", { id: "upload-opt" });
|
||||
const optimizedFiles = await Promise.all(
|
||||
validFiles.map(async (file) => {
|
||||
if (postType === "video") {
|
||||
return optimizeVideoToMp4(file, (p) =>
|
||||
toast.loading(`فشردهسازی ویدئو ${p}%`, { id: "upload-opt" })
|
||||
);
|
||||
}
|
||||
return optimizeImageToWebP(file);
|
||||
})
|
||||
);
|
||||
toast.dismiss("upload-opt");
|
||||
|
||||
const filesBase64 = await Promise.all(
|
||||
optimizedFiles.map(async (file) => ({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
data: await fileToBase64(file),
|
||||
}))
|
||||
);
|
||||
|
||||
const tagLine = taggedUsers
|
||||
.map((u) => `@${u.user_name}`)
|
||||
.join(" ");
|
||||
const caption =
|
||||
[description, tagLine].filter(Boolean).join("\n") || description;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
files: filesBase64,
|
||||
caption,
|
||||
tagged_user_ids: taggedUsers.map((u) => u._id),
|
||||
};
|
||||
|
||||
console.log("DEBUG: Payload to send:", JSON.stringify(payload, null, 2));
|
||||
console.log("DEBUG: Authorization header:", Cookies.get('token') ? `Bearer ${Cookies.get('token')}` : 'No token');
|
||||
|
||||
const response = await request("POST", "/posts/create-base64", payload, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
console.log("DEBUG: Response from server:", response);
|
||||
toast.success("پست با موفقیت آپلود شد");
|
||||
router.push("/settings/profile");
|
||||
} catch (err: unknown) {
|
||||
// اگر err یک Error معمولی باشه
|
||||
if (err instanceof Error) {
|
||||
console.error("DEBUG: Upload error:", err.message);
|
||||
toast.error("خطا در آپلود پست: " + err.message);
|
||||
}
|
||||
// اگر err یه ساختار مشابه Axios error باشه
|
||||
else if (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"response" in err &&
|
||||
typeof (err as { response?: { data?: { message?: string } } }).response?.data?.message === "string"
|
||||
) {
|
||||
const msg = (err as { response: { data: { message: string } } }).response.data.message;
|
||||
console.error("DEBUG: Upload error:", msg);
|
||||
toast.error("خطا در آپلود پست: " + msg);
|
||||
} else {
|
||||
// حالت پیش فرض
|
||||
console.error("DEBUG: Upload error:", err);
|
||||
toast.error("خطا در ارتباط با سرور");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const newFiles = [...postFiles];
|
||||
const newPreviews = [...previews];
|
||||
|
||||
// آزادسازی URL برای فایل حذف شده
|
||||
if (newPreviews[index]) {
|
||||
URL.revokeObjectURL(newPreviews[index]);
|
||||
}
|
||||
|
||||
newFiles.splice(index, 1);
|
||||
newPreviews.splice(index, 1);
|
||||
|
||||
// اگر نوع پست image است و تعداد فایلها کمتر از 10 شد، یک slot خالی اضافه میکنیم
|
||||
if (postType === "image" && newFiles.filter((f) => f !== null).length < 10) {
|
||||
newFiles.push(null);
|
||||
newPreviews.push("");
|
||||
}
|
||||
|
||||
setPostFiles(newFiles);
|
||||
setPreviews(newPreviews);
|
||||
};
|
||||
|
||||
|
||||
const selectPostType = (type: "image" | "video" | null) => {
|
||||
setPostType(type);
|
||||
|
||||
// آزادسازی همه previews قدیمی
|
||||
previews.forEach((url) => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
if (type === "image") {
|
||||
setPostFiles([null]);
|
||||
setPreviews([""]);
|
||||
} else {
|
||||
setPostFiles([]);
|
||||
setPreviews([]);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Container >
|
||||
<div className="flex flex-col items-center mb-4 px-3">
|
||||
<span className="text-xl font-bold mt-5">ثبت پست</span>
|
||||
|
||||
<div className="flex gap-4 mt-10 ">
|
||||
<RoundedButton onClick={() => selectPostType("image")} className="w-36 h-10 active:bg-white focus:bg-slate-800">
|
||||
ثبت تصاویر
|
||||
</RoundedButton>
|
||||
<RoundedButton onClick={() => selectPostType("video")} className="w-36 h-10 active:bg-white focus:bg-slate-800">
|
||||
ثبت ویدئو
|
||||
</RoundedButton>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<div className="w-full max-w-md grid grid-cols-2 gap-4 mt-10">
|
||||
{postType === "video" && previews.length === 0 ? (
|
||||
<div className="w-full h-40 rounded-3xl border border-[#676767] flex items-center justify-center">
|
||||
<label
|
||||
htmlFor="fileInput-video"
|
||||
className="cursor-pointer w-full h-full flex items-center justify-center text-white"
|
||||
>
|
||||
<Image
|
||||
src="/images/icons/gallery-add.svg"
|
||||
width={80}
|
||||
height={80}
|
||||
alt="افزودن ویدئو"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
id="fileInput-video"
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={selectFiles(0)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
previews.map((preview, index) => (
|
||||
<div key={index} className="relative w-full h-40 rounded-3xl border border-[#676767] overflow-hidden flex items-center justify-center">
|
||||
{preview ? (
|
||||
<>
|
||||
{postFiles[index]?.type?.startsWith('video/') ? (
|
||||
<video
|
||||
src={preview}
|
||||
className="w-full h-full object-cover"
|
||||
controls
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={preview}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeFile(index)}
|
||||
className="p-2 absolute top-0 right-0"
|
||||
>
|
||||
<Image
|
||||
src="/images/icons/close-circle.svg"
|
||||
width={25}
|
||||
height={25}
|
||||
alt="حذف فایل"
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<label
|
||||
htmlFor={`fileInput-${index}`}
|
||||
className="cursor-pointer w-full h-full flex items-center justify-center text-white"
|
||||
>
|
||||
<Image
|
||||
src="/images/icons/gallery-add.svg"
|
||||
width={80}
|
||||
height={80}
|
||||
alt="افزودن تصویر"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={`fileInput-${index}`}
|
||||
type="file"
|
||||
accept={postType === "video" ? "video/*" : "image/*"}
|
||||
className="hidden"
|
||||
onChange={selectFiles(index)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
</>
|
||||
|
||||
</div>
|
||||
<div className="flex flex-col items-center max-w-md mt-8 mx-auto px-3">
|
||||
<textarea
|
||||
placeholder="توضیحات"
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length <= 1000) setDescription(e.target.value);
|
||||
}}
|
||||
className="w-full p-4 h-20 rounded-3xl border border-border-secondary-light dark:border-border-secondary-dark bg-secondary-light dark:bg-secondary-dark font-medium"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 mt-1">
|
||||
{description.length}/1000
|
||||
</span>
|
||||
<div className="mt-6 w-full">
|
||||
<TagUsersPicker
|
||||
selected={taggedUsers}
|
||||
onChange={setTaggedUsers}
|
||||
max={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<RoundedButton
|
||||
onClick={uploadPost}
|
||||
className="mt-10 w-40 h-10"
|
||||
disabled={loading || !postType}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 mr-2 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
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"
|
||||
></path>
|
||||
</svg>
|
||||
در حال آپلود...
|
||||
</div>
|
||||
) : (
|
||||
"ثبت پست"
|
||||
)}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewPost;
|
||||
220
src/app/offer/[id]/page.tsx
Normal file
220
src/app/offer/[id]/page.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import UserInfo from "@/components/main/UserInfo";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IOfferType, User } from "@/types/types";
|
||||
import axios from "axios";
|
||||
import { useFormik } from "formik";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
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(" انتخاب کردن نوع درخواست الزامی است"),
|
||||
});
|
||||
|
||||
interface ITicketChatProps {
|
||||
params: Promise<{ id: string; title: string }>;
|
||||
}
|
||||
|
||||
function TicketChat({ params }: ITicketChatProps) {
|
||||
const resolvedParams = React.use(params);
|
||||
const { request } = useAxios();
|
||||
const { id } = resolvedParams;
|
||||
const router = useRouter();
|
||||
|
||||
const [userDetail, setUserDetail] = useState<User>();
|
||||
const [selectedType, setSelectedType] = useState("normal");
|
||||
const [typeList, setTypeList] = useState<IOfferType[] | null>(null);
|
||||
const [hasUsedFreeOffer, setHasUsedFreeOffer] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
const response = await request<{ user: User }>(
|
||||
"GET",
|
||||
`/users/get?user_id=${id}`
|
||||
);
|
||||
setUserDetail(response?.user);
|
||||
setHasUsedFreeOffer(response?.user?.hasUsedFreeOffer || false);
|
||||
};
|
||||
fetchUser();
|
||||
}, [id]);
|
||||
|
||||
const fetchOfferTypes = async () => {
|
||||
try {
|
||||
const response = await request<{ offerTypes: IOfferType[] }>(
|
||||
"GET",
|
||||
"/offers/types"
|
||||
);
|
||||
setTypeList(response?.offerTypes || null);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
fetchOfferTypes();
|
||||
}, []);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedType: "normal",
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
if (values.selectedType === "free" && hasUsedFreeOffer) {
|
||||
toast.error("شما قبلاً از درخواست رایگان استفاده کردهاید!");
|
||||
return;
|
||||
}
|
||||
|
||||
const getToken = (): string => {
|
||||
if (typeof window !== "undefined") {
|
||||
const token = Cookies.get("token");
|
||||
return token ? `Bearer ${token}` : "";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${BASE_URL}/offers/initiate-payment-web`, {
|
||||
offerType: values.selectedType,
|
||||
receiverId: id,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: getToken(), // ✅ اضافه شدن توکن به هدر
|
||||
"Content-Type": "application/json", // اختیاری ولی بهتره باشه
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data: { authority?: string; offerId?: string; type?: string } = response.data;
|
||||
|
||||
console.log("Authority:", data.authority);
|
||||
console.log("Response:", response);
|
||||
if (values.selectedType === "free") {
|
||||
toast.success("درخواست رایگان شما ثبت شد!");
|
||||
setHasUsedFreeOffer(true);
|
||||
router.push(`/offer/payment/success?userId=${id}`);
|
||||
}
|
||||
|
||||
|
||||
else if (data.authority) {
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
|
||||
console.log("Redirecting to:", paymentUrl);
|
||||
window.open(paymentUrl, "_blank");
|
||||
} else {
|
||||
toast.error("خطا در دریافت اطلاعات پرداخت");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("Payment initiation error:", err);
|
||||
toast.error(err?.response?.data?.error || "خطا در شروع پرداخت");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="mx-3 mb-10">
|
||||
|
||||
<PageTitle>پرداخت</PageTitle>
|
||||
<p className="text-center">
|
||||
شما فقط 1 بار میتوانید درخواست رایگان ثبت کنید
|
||||
</p>
|
||||
<form
|
||||
className="flex flex-col gap-2 w-full items-center text-sm"
|
||||
onSubmit={formik.handleSubmit}
|
||||
>
|
||||
{typeList?.map((item: IOfferType) => {
|
||||
const isFree = item.name === "free";
|
||||
const isDisabled = isFree && hasUsedFreeOffer;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!isDisabled) {
|
||||
setSelectedType(item?.name);
|
||||
formik.setFieldValue("selectedType", item?.name);
|
||||
}
|
||||
}}
|
||||
className="w-full flex flex-col items-center max-w-md mt-8 gap-4 cursor-pointer"
|
||||
key={item?._id}
|
||||
>
|
||||
<div className="flex items-end justify-between w-full">
|
||||
<UserInfo
|
||||
first_name={userDetail?.first_name}
|
||||
is_verified={userDetail?.is_verified}
|
||||
profile_image={userDetail?.profile_image}
|
||||
user_level={userDetail?.user_level}
|
||||
last_name={userDetail?.last_name}
|
||||
user_name={userDetail?.user_name}
|
||||
noLink
|
||||
/>
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<span>{userDetail?.user_score || "0"}</span>
|
||||
<Image
|
||||
width={21}
|
||||
height={21}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/medal-star.svg`}
|
||||
className="pb-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span>{userDetail?.rate || "0"}</span>
|
||||
<Image
|
||||
width={21}
|
||||
height={21}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/star1.svg`}
|
||||
className="pb-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RoundedDiv
|
||||
className={`w-full max-w-full p-2 ${
|
||||
selectedType === item?.name ? "bg-[#FC8EAC]" : ""
|
||||
} ${isDisabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
{item.name === "normal"
|
||||
? "درخواست همکاری"
|
||||
: item.name === "free"
|
||||
? "ثبت درخواست رایگان"
|
||||
: item.name === "special"
|
||||
? "نمایش با آیکون ویژه"
|
||||
: "نمایش با رنگ پس زمینه متفاوت"}
|
||||
{item.price !== 0
|
||||
? ": " + Number(item.price).toLocaleString() + "تومان"
|
||||
: ""}
|
||||
</RoundedDiv>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<RoundedButton type="submit" className="p-2 px-8 rounded mt-4">
|
||||
ثبت درخواست
|
||||
</RoundedButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketChat;
|
||||
16
src/app/offer/layout.tsx
Normal file
16
src/app/offer/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
|
||||
export default function ModelsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<TabNavigation currentPage="/" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
69
src/app/offer/payment-web/route.js
Normal file
69
src/app/offer/payment-web/route.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// src/app/offer/payment-web/route.js
|
||||
export async function GET(request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const Status = searchParams.get("Status");
|
||||
const Authority = searchParams.get("Authority");
|
||||
|
||||
// ❗ چک ضروری
|
||||
if (Status !== "OK" || !Authority) {
|
||||
return new Response(
|
||||
`<div style="text-align:center;margin-top:100px;font-family:tahoma;direction:rtl">
|
||||
<h2 style="color:#e74c3c">✗ پرداخت لغو شد یا ناموفق بود</h2>
|
||||
<a href="/">برگشت به خانه</a>
|
||||
</div>`,
|
||||
{ status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } }
|
||||
);
|
||||
}
|
||||
|
||||
const receiverId = searchParams.get("receiverId");
|
||||
const offerType = searchParams.get("offerType");
|
||||
const userId = searchParams.get("userId");
|
||||
|
||||
const backendUrl = `https://app.modstagram.ir/api/v1/offers/payment-web?receiverId=${receiverId}&offerType=${offerType}&userId=${userId}&Authority=${Authority}&Status=${Status}`;
|
||||
|
||||
|
||||
try {
|
||||
const backendResponse = await fetch(backendUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const result = await backendResponse.json();
|
||||
|
||||
if (backendResponse.ok && result.success) {
|
||||
return new Response(
|
||||
`<div style="text-align:center;margin-top:100px;font-family:tahoma;direction:rtl">
|
||||
<h2 style="color:#27ae60">✓ پرداخت با موفقیت انجام شد</h2>
|
||||
<p>شماره تراکنش: <b>${result.ref_id || "دریافت شد"}</b></p>
|
||||
<p>آفر با موفقیت ارسال شد ✅</p>
|
||||
<script>setTimeout(()=>location.href="/", 5000)</script>
|
||||
<a href="/">برگشت به خانه</a>
|
||||
</div>`,
|
||||
{ status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } }
|
||||
);
|
||||
} else {
|
||||
return new Response(
|
||||
`<div style="text-align:center;margin-top:100px;font-family:tahoma;direction:rtl">
|
||||
<h2 style="color:#e74c3c">✗ خطا در ثبت آفر</h2>
|
||||
<p>${result.message || "خطای ناشناخته"}</p>
|
||||
<a href="/">برگشت به خانه</a>
|
||||
</div>`,
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Callback Error:', err); // برای دیباگ
|
||||
return new Response(
|
||||
`<div style="text-align:center;margin-top:100px;font-family:tahoma;direction:rtl">
|
||||
<h2 style="color:#e74c3c">خطای سرور</h2>
|
||||
<p>لطفاً دوباره تلاش کنید</p>
|
||||
<a href="/">برگشت به خانه</a>
|
||||
</div>`,
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/app/offer/payment/failed/page.tsx
Normal file
41
src/app/offer/payment/failed/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React from "react";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
function FailedBillboard() {
|
||||
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] ">
|
||||
پرداخت ناموفق
|
||||
</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-8"
|
||||
/>
|
||||
<span> پرداخت شما با خطا مواجه شد. </span>
|
||||
</div>
|
||||
<Link
|
||||
className="flex items-center flex-col mt-8"
|
||||
href={`/offer/${userId}`}
|
||||
>
|
||||
<RoundedButton className="w-32 h-9 !border-[#0C8002] text-[#0C8002]">
|
||||
پرداخت مجدد
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default FailedBillboard;
|
||||
66
src/app/offer/payment/success/page.tsx
Normal file
66
src/app/offer/payment/success/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import { User } from "@/types/types";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
function SuccessBillboard() {
|
||||
const { request } = useAxios();
|
||||
const searchParams = useSearchParams();
|
||||
const userId = searchParams.get("userId");
|
||||
|
||||
const [userDetail, setUserDetail] = useState<User | null>(null);
|
||||
useEffect(() => {
|
||||
const fetchStates = async () => {
|
||||
const response = await request<{ user: User }>(
|
||||
"GET",
|
||||
`/users/get?user_id=${userId}`
|
||||
);
|
||||
setUserDetail(response?.user);
|
||||
};
|
||||
fetchStates();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h6 className="font-bold md:text-xl text-lg text-center mt-10 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">
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<p className="my-5">
|
||||
یک درخواست همکاری برای کاربر {userDetail?.user_name} ثبت شد
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Link href={`/settings/chats/${userDetail?.user_name}/${userId}`}>
|
||||
<RoundedButton className="w-32 h-9 !border-[#0C8002] text-[#0C8002]">
|
||||
ارسال پیام
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
<Link href={"/"}>
|
||||
<RoundedButton className="w-32 h-9 !border-[#0C8002] text-[#0C8002]">
|
||||
بعدا
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SuccessBillboard;
|
||||
8
src/app/posts/[id]/layout.tsx
Normal file
8
src/app/posts/[id]/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
/** Post viewer — no bottom tab bar (full-screen feed) */
|
||||
export default function PostLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
80
src/app/posts/[id]/page.tsx
Normal file
80
src/app/posts/[id]/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Metadata } from "next";
|
||||
import { fetchPostById } from "@/api/fetchPostById";
|
||||
import PostFeedView from "@/components/posts/PostFeedView";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { Suspense } from "react";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
|
||||
type Props = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const post = await fetchPostById(id);
|
||||
const title = post?.caption
|
||||
? `${post.caption.slice(0, 60)} | مادستاگرام`
|
||||
: `پست | مادستاگرام`;
|
||||
const description =
|
||||
post?.caption?.slice(0, 160) ||
|
||||
"مشاهده پست در مادستاگرام — پلتفرم مدلینگ و تبلیغات";
|
||||
const imagePath = post?.files?.[0]?.path?.replace(
|
||||
"/root/modstagram-back/storage",
|
||||
""
|
||||
);
|
||||
const ogImage = imagePath ? `${IMAGE_BASE_URL}${imagePath}` : undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: `https://modstagram.com/posts/${id}` },
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: `https://modstagram.com/posts/${id}`,
|
||||
type: "article",
|
||||
images: ogImage ? [{ url: ogImage, width: 1080, height: 1350 }] : [],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
description,
|
||||
images: ogImage ? [ogImage] : [],
|
||||
},
|
||||
robots: { index: true, follow: true },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PostPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
const post = await fetchPostById(id);
|
||||
const userId = post?.userId || post?.user_id;
|
||||
|
||||
return (
|
||||
<main className="min-h-[100dvh]">
|
||||
{post && (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SocialMediaPosting",
|
||||
headline: post.caption?.slice(0, 110) || "پست مادستاگرام",
|
||||
url: `https://modstagram.com/posts/${id}`,
|
||||
datePublished: post.createdAt,
|
||||
author: {
|
||||
"@type": "Person",
|
||||
name: `${post.first_name || ""} ${post.last_name || ""}`.trim(),
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Suspense fallback={<PageLoader className="min-h-[100dvh]" />}>
|
||||
<PostFeedView
|
||||
initialPostId={id}
|
||||
userId={userId}
|
||||
showClose
|
||||
/>
|
||||
</Suspense>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
35
src/app/robots.ts
Normal file
35
src/app/robots.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
// اجازه دسترسی به صفحات اصلی و آرشیوها
|
||||
allow: [
|
||||
'/',
|
||||
'/videos',
|
||||
'/billboards',
|
||||
'/users',
|
||||
'/posts'
|
||||
],
|
||||
// جلوگیری از ایندکس صفحات سیستمی و شخصی
|
||||
disallow: [
|
||||
'/api/',
|
||||
'/_next/',
|
||||
'/settings',
|
||||
'/auth',
|
||||
'/private/',
|
||||
'/new-post',
|
||||
'/new-project',
|
||||
'/search',
|
||||
'/*/payment/', // صفحات موفقیت یا شکست پرداخت
|
||||
'/verify/',
|
||||
'/register',
|
||||
'/login',
|
||||
'/*?*', // جلوگیری از ایندکس شدن لینکهای دارای فیلتر و کوئری استرینگ (جلوگیری از محتوای تکراری)
|
||||
],
|
||||
},
|
||||
// معرفی نقشه سایت به گوگل
|
||||
sitemap: 'https://modstagram.com/sitemap.xml',
|
||||
}
|
||||
}
|
||||
12
src/app/search/layout.tsx
Normal file
12
src/app/search/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<TabNavigation currentPage="/" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
70
src/app/search/page.tsx
Normal file
70
src/app/search/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import Container from "@/components/elements/Container";
|
||||
import { cookies } from "next/headers";
|
||||
import SearchFilter from "@/components/search/SearchFilter";
|
||||
import { fetchSearch } from "@/api/fetchSearch";
|
||||
import InfiniteSearch from "@/components/search/InfiniteSearch";
|
||||
import { Metadata } from "next";
|
||||
|
||||
interface ISearchProps {
|
||||
searchParams: Promise<{
|
||||
search?: string;
|
||||
type?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// تبدیل متادیتای ثابت به داینامیک
|
||||
export async function generateMetadata({ searchParams }: ISearchProps): Promise<Metadata> {
|
||||
const { search } = await searchParams;
|
||||
|
||||
const title = search
|
||||
? `نتایج جستجو برای "${search}" | مدستاگرام`
|
||||
: "جستجوی بیلبوردها و پروفایلها | مدستاگرام";
|
||||
|
||||
const description = search
|
||||
? `نتایج جستجوی تخصصی برای ${search} در حوزه مد و زیبایی مدستاگرام.`
|
||||
: "جستجو و فیلتر بیلبورد، فروشگاه، شهر و دستهبندی در پلتفرم مدستاگرام.";
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: "https://modstagram.com/search",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: "https://modstagram.com/search",
|
||||
siteName: "مدستاگرام",
|
||||
locale: "fa_IR",
|
||||
type: "website",
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Search({ searchParams }: ISearchProps) {
|
||||
const token = (await cookies()).get("token")?.value || "";
|
||||
const filters = await searchParams;
|
||||
|
||||
const initialData =
|
||||
filters.search && filters.type
|
||||
? await fetchSearch(1, 10, filters, token)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* اضافه کردن H1 مخفی برای بهبود سئو */}
|
||||
<h1 className="sr-only">
|
||||
{filters.search
|
||||
? `نتایج جستجوی مدستاگرام برای: ${filters.search}`
|
||||
: "جستجوی تخصصی در پلتفرم مد و زیبایی مدستاگرام"}
|
||||
</h1>
|
||||
|
||||
<SearchFilter />
|
||||
<InfiniteSearch
|
||||
initialData={initialData}
|
||||
filters={filters}
|
||||
token={token}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
19
src/app/settings/academy/Components/Box.tsx
Normal file
19
src/app/settings/academy/Components/Box.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Title from "@/Components/ui/Title";
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
interface BoxProps {
|
||||
children: ReactNode;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function Box({ children, text }: BoxProps) {
|
||||
return (
|
||||
<div className="flex justify-center mt-5 items-center mx-2.5">
|
||||
<div className="px-3 py-7 rounded-xl bg-white max-w-[1500px] dark:bg-neutral-900 w-full shadow-xl">
|
||||
<Title text={text} className="text-left mb-8 mx-3.5 " />
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/app/settings/academy/Components/OrdersCart.tsx
Normal file
107
src/app/settings/academy/Components/OrdersCart.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
import React from "react";
|
||||
import { IconArrowBack } from "@tabler/icons-react";
|
||||
import { GrTasks } from "react-icons/gr";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Image from "next/image";
|
||||
import { FaFileInvoice } from "react-icons/fa";
|
||||
import { IoIosArrowForward } from "react-icons/io";
|
||||
import { FcRating } from "react-icons/fc";
|
||||
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
date: string;
|
||||
orderCode: string;
|
||||
amount: string;
|
||||
statusLabel: string;
|
||||
statusDetail?: string;
|
||||
images: string[];
|
||||
rating: number;
|
||||
invoiceUrl?: string;
|
||||
};
|
||||
|
||||
export default function OrdersCart({ order }: { order: OrderItem }) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full border-2 gap-y-3.5 rounded-xl p-3.5 flex flex-col items-center">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex justify-center items-center gap-2.5">
|
||||
<span className="">
|
||||
<GrTasks className="size-7 text-cyan-600 dark:text-neutral-600" />
|
||||
</span>
|
||||
<span className="font-bold text-neutral-600 dark:text-neutral-300">
|
||||
{order.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span className="ltr:rotate-180">
|
||||
<IconArrowBack className="text-neutral-600 dark:text-neutral-300" />
|
||||
</span>
|
||||
</div>
|
||||
<div className=" text-sm max-sm:text-xs w-full flex gap-3.5">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<span className="font-medium text-neutral-400">
|
||||
{t("orderCart.date")}:{" "}
|
||||
</span>
|
||||
<span className="font-bold text-neutral-800 dark:text-neutral-200">
|
||||
{order.date}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<span className="font-medium text-neutral-400">
|
||||
{t("orderCart.Order_code")}:{" "}
|
||||
</span>
|
||||
<span className="font-bold text-neutral-800 dark:text-neutral-200">
|
||||
{order.orderCode}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<span className="font-medium text-neutral-400">
|
||||
{t("orderCart.Amount")}:{" "}
|
||||
</span>
|
||||
<span className="font-bold text-neutral-800 dark:text-neutral-200">
|
||||
{order.amount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className=" max-sm:text-sm w-full flex gap-3.5">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<span className="font-medium text-neutral-400">
|
||||
{t("orderCart.Order_status")}:{" "}
|
||||
</span>
|
||||
<span className="font-bold text-cyan-600 dark:text-neutral-600">
|
||||
{order.statusDetail}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex gap-2 flex-wrap">
|
||||
{order.images.map((src, idx) => (
|
||||
<Image key={idx} alt={`img-${idx}`} src={src} height={70} width={70} className="object-cover object-center" />
|
||||
))}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex justify-center items-center gap-2.5">
|
||||
<span className="font-medium max-sm:hidden text-neutral-400">
|
||||
{t("orderCart.Your_rating_for_this_order")}:{" "}
|
||||
</span>
|
||||
<span className="font-bold flex justify-center items-center gap-0.5 flex-row-reverse text-neutral-600 dark:text-neutral-300">
|
||||
{order.rating} <FcRating className="size-5"/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex cursor-pointer justify-center items-center gap-2.5">
|
||||
<span className="">
|
||||
<FaFileInvoice className="size-5 text-cyan-800 dark:text-neutral-200" />
|
||||
</span>
|
||||
<span className="font-bold flex justify-center items-center text-cyan-800 dark:text-neutral-200">
|
||||
{t("orderCart.View_invoice")} <IoIosArrowForward className="rtl:rotate-180" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
src/app/settings/academy/Dashboard/Components/cart.tsx
Normal file
31
src/app/settings/academy/Dashboard/Components/cart.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CartInterface {
|
||||
src: string;
|
||||
order: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function Cart(props: CartInterface) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div className="flex flex-row gap-2 ">
|
||||
<Image
|
||||
src={props.src}
|
||||
alt="Panel image"
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
|
||||
<div className="font-bold justify-evenly flex flex-col ">
|
||||
<p className="text-2xl text-neutral-800 dark:text-neutral-300">
|
||||
{props.order} {t("panel.Order")}
|
||||
</p>
|
||||
<p className="text-neutral-700 dark:text-neutral-400"> {props.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/app/settings/academy/Dashboard/page.tsx
Normal file
37
src/app/settings/academy/Dashboard/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
|
||||
|
||||
import { SectionCards } from "@/components/section-cards"
|
||||
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "calc(var(--spacing) * 72)",
|
||||
"--header-height": "calc(var(--spacing) * 12)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
|
||||
<SidebarInset>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<SectionCards />
|
||||
<div className="px-4 lg:px-6">
|
||||
<ChartAreaInteractive />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
10
src/app/settings/academy/course/page.tsx
Normal file
10
src/app/settings/academy/course/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
import { CourseManager } from "@/components/course/CourseManager";
|
||||
|
||||
export default function CoursesPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<CourseManager />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/app/settings/academy/layout.tsx
Normal file
29
src/app/settings/academy/layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "calc(var(--spacing) * 72)",
|
||||
"--header-height": "calc(var(--spacing) * 12)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset className="">
|
||||
<SiteHeader />
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</ThemeProvider>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
446
src/app/settings/academy/my-courses/page.tsx
Normal file
446
src/app/settings/academy/my-courses/page.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
"use client";
|
||||
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import React, { useEffect, useState, useCallback } 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";
|
||||
|
||||
interface PaginationData {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPrevPage: boolean;
|
||||
}
|
||||
|
||||
export default function PurchasedCoursesPage() {
|
||||
const { request } = useAxios();
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [pagination, setPagination] = useState<PaginationData>({
|
||||
page: 1,
|
||||
limit: 5,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false,
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const fetchCourses = async (page: number) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await request(
|
||||
"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,
|
||||
limit: response.data.pagination.limit,
|
||||
total: response.data.pagination.total,
|
||||
totalPages: response.data.pagination.totalPages,
|
||||
hasNextPage: response.data.pagination.hasNextPage,
|
||||
hasPrevPage: response.data.pagination.hasPrevPage,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setCourses([]);
|
||||
toast.error("خطا در دریافت اطلاعات");
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("get error:", err);
|
||||
toast.error("خطا در دریافت دورههای خریداری شده");
|
||||
setCourses([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourses(currentPage);
|
||||
}, [currentPage]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage >= 1 && newPage <= pagination.totalPages) {
|
||||
setCurrentPage(newPage);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="relative w-24 h-24 mx-auto">
|
||||
<div className="absolute inset-0 rounded-full border-4 border-gray-200 dark:border-gray-700"></div>
|
||||
<div className="absolute inset-0 rounded-full border-4 border-t-blue-500 border-r-purple-500 border-b-pink-500 border-l-transparent animate-spin"></div>
|
||||
</div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-300 font-medium">
|
||||
در حال بارگذاری دورههای شما...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
) : (
|
||||
<>
|
||||
{/* آمار دورهها */}
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</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
|
||||
? "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
: "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>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map(
|
||||
(page) => {
|
||||
// نمایش حداکثر 5 صفحه
|
||||
if (
|
||||
page === 1 ||
|
||||
page === pagination.totalPages ||
|
||||
(page >= currentPage - 1 && page <= currentPage + 1)
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => handlePageChange(page)}
|
||||
className={`
|
||||
min-w-[40px] h-10 rounded-lg font-medium transition-all duration-200
|
||||
${
|
||||
currentPage === page
|
||||
? "bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-md"
|
||||
: "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
// نمایش نقطه چین
|
||||
if (
|
||||
page === currentPage - 2 ||
|
||||
page === currentPage + 2
|
||||
) {
|
||||
return (
|
||||
<span
|
||||
key={page}
|
||||
className="w-10 h-10 flex items-center justify-center text-gray-400"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={!pagination.hasNextPage}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg transition-all duration-200
|
||||
${
|
||||
pagination.hasNextPage
|
||||
? "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
: "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>
|
||||
)}
|
||||
|
||||
{/* دکمه بازگشت به بالا */}
|
||||
{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"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
379
src/app/settings/academy/storeProfile/page.tsx
Normal file
379
src/app/settings/academy/storeProfile/page.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
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";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { Academy } from "@/types/types";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
|
||||
// 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(),
|
||||
});
|
||||
|
||||
type StoreFormValues = z.infer<typeof storeSchema>;
|
||||
|
||||
|
||||
|
||||
interface AcademyResponse {
|
||||
academy: Academy;
|
||||
}
|
||||
|
||||
//
|
||||
type TagType = { value: string };
|
||||
|
||||
export default function StoreSettings() {
|
||||
const { request, loading, error } = useAxios();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation("common");
|
||||
const [previewProfile, setPreviewProfile] = useState<string>("");
|
||||
const [avatar, setAvatar] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFetching, setIsFetching] = useState(true); // برای نمایش لودینگ اولیه
|
||||
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const form = useForm<StoreFormValues>({
|
||||
resolver: zodResolver(storeSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
bio: "",
|
||||
profileImage: "",
|
||||
sheba: "",
|
||||
tags: [],
|
||||
},
|
||||
});
|
||||
|
||||
const parseTags = (jsonString: string): TagType[] => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
if (Array.isArray(parsed)) {
|
||||
const validTags = parsed.filter(
|
||||
(item) =>
|
||||
item && typeof item === "object" && typeof item.value === "string"
|
||||
);
|
||||
return validTags as TagType[];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("خطا در parse تگها:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// دریافت دیتا از سرور
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsFetching(true);
|
||||
const response = (await request("GET", `/academy`)) as AcademyResponse;
|
||||
|
||||
if (response?.academy) {
|
||||
const tagsArray = parseTags(response.academy.tag || "");
|
||||
|
||||
// تنظیم مقادیر فرم
|
||||
form.reset({
|
||||
name: response.academy.academy_name || "",
|
||||
bio: response.academy.bio || "",
|
||||
profileImage: response.academy.academy_image || "",
|
||||
sheba: response.academy.sheba || "",
|
||||
tags: tagsArray,
|
||||
});
|
||||
|
||||
// تنظیم پیشنمایش عکس
|
||||
if (response.academy.academy_image) {
|
||||
setPreviewProfile(response.academy.academy_image);
|
||||
setAvatar(response.academy.academy_image);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("خطا در دریافت اطلاعات:", error);
|
||||
toast.error("خطا در دریافت اطلاعات");
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
fields: tagFields,
|
||||
append: appendTag,
|
||||
remove: removeTag,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "tags",
|
||||
});
|
||||
|
||||
const selectImage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
const file = event.target.files[0];
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
setPreviewProfile(previewUrl);
|
||||
setAvatar(previewUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: StoreFormValues) => {
|
||||
setIsLoading(true);
|
||||
|
||||
if (!data.name) {
|
||||
toast.error("نام را پر کنید");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!data.sheba) {
|
||||
toast.error("شبا را پر کنید");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!data.bio) {
|
||||
toast.error("بیو را پر کنید");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileInput = document.getElementById("fileInput") as HTMLInputElement;
|
||||
if (!fileInput?.files?.[0]) {
|
||||
toast.error("لطفا تصویر پروفایل را انتخاب کنید");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("profile_image", fileInput.files[0]);
|
||||
formData.append("mobile", mobile as string);
|
||||
formData.append("name", data.name);
|
||||
formData.append("sheba", data.sheba);
|
||||
formData.append("bio", data.bio);
|
||||
formData.append("tag", JSON.stringify(data.tags || []));
|
||||
|
||||
try {
|
||||
await request("POST", "/academy/academy/profile", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
toast.success("ثبت شد");
|
||||
router.push("/settings/academy/Dashboard");
|
||||
} catch (err) {
|
||||
console.log("Upload error:", err);
|
||||
toast.error("خطا در ثبت اطلاعات");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// نمایش لودینگ در حین دریافت دیتا
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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"
|
||||
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">
|
||||
{"مشخصات آموزشگاه"}
|
||||
</h2>
|
||||
<Form {...form}>
|
||||
<form
|
||||
dir="rtl"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-8"
|
||||
>
|
||||
<ScrollArea
|
||||
dir="rtl"
|
||||
className="max-h-[80vh] p-4 border flex flex-col"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{"نام آموزشگاه"}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"نام آموزشگاه"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sheba"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{"شبا"}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"شبا"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Separator className="mb-3" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bio"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="mt-1.5">{"بیو"}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={"درباره فروشگاه خود بنویسید"}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Separator className="mb-3" />
|
||||
<FormItem>
|
||||
<FormLabel className="mt-1.5">{"عکس پروفایل"}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
</FormControl>
|
||||
{previewProfile && (
|
||||
<motion.img
|
||||
src={IMAGE_BASE_URL + previewProfile}
|
||||
alt="Profile Preview"
|
||||
className="w-24 h-24 rounded-xl object-cover mt-2"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
/>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<Separator className="mb-3" />
|
||||
<FormItem>
|
||||
<FormLabel className="mt-1.5">{"تگ های فروشگاه"}</FormLabel>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{tagFields.map((tag, index) => (
|
||||
<Badge key={tag.id}>
|
||||
{tag.value}
|
||||
<Button type="button" onClick={() => removeTag(index)}>
|
||||
X
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Input
|
||||
placeholder={"تگ"}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.currentTarget.value) {
|
||||
appendTag({ value: e.currentTarget.value });
|
||||
e.currentTarget.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="تگ"]'
|
||||
) as HTMLInputElement;
|
||||
if (input.value) {
|
||||
appendTag({ value: input.value });
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{"ثبت"}
|
||||
</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 }}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="bg-gradient-to-r from-primary w-11/12 to-primary/80"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 ml-2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
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>
|
||||
"در حال ثبت..."
|
||||
</>
|
||||
) : (
|
||||
"ثبت"
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</ScrollArea>
|
||||
</form>
|
||||
</Form>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
412
src/app/settings/academy/wallet/page.tsx
Normal file
412
src/app/settings/academy/wallet/page.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ThemeProvider, useTheme } from "next-themes";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
|
||||
interface Payment {
|
||||
_id: string;
|
||||
price: number;
|
||||
course_name: string;
|
||||
course_id: any;
|
||||
academy_id: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
payment_authority: string;
|
||||
payment_ref_id: string;
|
||||
discountAmount: number;
|
||||
taxAmount: number;
|
||||
taxRate: number;
|
||||
user_id: {
|
||||
_id: string;
|
||||
user_name: string;
|
||||
profile_image: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
type: "deposit" | "withdraw" | "expense";
|
||||
currency: string;
|
||||
amount: number;
|
||||
date: string;
|
||||
description: string;
|
||||
status: string;
|
||||
refId: string;
|
||||
}
|
||||
|
||||
const WalletDashboard = () => {
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [filterType, setFilterType] = useState<"all" | "deposit" | "withdraw" | "expense">("all");
|
||||
const [totalBalance, setTotalBalance] = useState(0);
|
||||
const [totalSettled, setTotalSettled] = useState(0);
|
||||
const [totalPending, setTotalPending] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { request } = useAxios();
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
// دریافت پرداختهای موفق (فروشهای انجام شده)
|
||||
const fetchSuccessfulPayments = async () => {
|
||||
try {
|
||||
const response = await request(
|
||||
"GET",
|
||||
`/academy/course/getAllAcademyPayments?status=success`
|
||||
);
|
||||
|
||||
console.log("پرداختهای موفق:", response?.data?.payments);
|
||||
|
||||
if (response?.data?.payments && Array.isArray(response.data.payments)) {
|
||||
const payments: Payment[] = response.data.payments;
|
||||
|
||||
// محاسبه مجموع فروش
|
||||
const total = payments.reduce((sum, p) => sum + p.taxAmount, 0);
|
||||
setTotalPending(total);
|
||||
|
||||
// تبدیل به تراکنشهای فروش ( expense type )
|
||||
const salesTransactions: Transaction[] = payments.map((payment) => ({
|
||||
id: payment._id,
|
||||
type: "expense",
|
||||
currency: "تومان",
|
||||
amount: payment.taxAmount,
|
||||
date: new Date(payment.createdAt).toLocaleDateString("fa-IR"),
|
||||
description: payment.course_name,
|
||||
status: "pending",
|
||||
refId: payment.payment_ref_id,
|
||||
}));
|
||||
|
||||
return salesTransactions;
|
||||
}
|
||||
return [];
|
||||
} catch (err) {
|
||||
console.log("error fetching successful payments:", err);
|
||||
toast.error("خطا در دریافت فروشها");
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// دریافت پرداختهای تسویه شده
|
||||
const fetchSettledPayments = async () => {
|
||||
try {
|
||||
const response = await request(
|
||||
"GET",
|
||||
`/academy/course/getAllAcademyPayments?status=settled`
|
||||
);
|
||||
|
||||
console.log("پرداختهای تسویه شده:", response?.data?.payments);
|
||||
|
||||
if (response?.data?.payments && Array.isArray(response.data.payments)) {
|
||||
const payments: Payment[] = response.data.payments;
|
||||
|
||||
// محاسبه مجموع تسویه شده
|
||||
const total = payments.reduce((sum, p) => sum + p.taxAmount, 0);
|
||||
setTotalSettled(total);
|
||||
|
||||
// تبدیل به تراکنشهای پرداخت شده ( withdraw type )
|
||||
const settledTransactions: Transaction[] = payments.map((payment) => ({
|
||||
id: payment._id,
|
||||
type: "withdraw",
|
||||
currency: "تومان",
|
||||
amount: payment.taxAmount,
|
||||
date: new Date(payment.updatedAt).toLocaleDateString("fa-IR"),
|
||||
description: `تسویه حساب - ${payment.course_name}`,
|
||||
status: "settled",
|
||||
refId: payment.payment_ref_id,
|
||||
}));
|
||||
|
||||
return settledTransactions;
|
||||
}
|
||||
return [];
|
||||
} catch (err) {
|
||||
console.log("error fetching settled payments:", err);
|
||||
toast.error("خطا در دریافت تسویهها");
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// محاسبه موجودی کل (فروش - تسویه شده)
|
||||
const calculateBalance = (sales: number, settled: number) => {
|
||||
return sales - settled;
|
||||
};
|
||||
|
||||
// بارگذاری تمام دادهها
|
||||
const fetchAllData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [salesTransactions, settledTransactions] = await Promise.all([
|
||||
fetchSuccessfulPayments(),
|
||||
fetchSettledPayments(),
|
||||
]);
|
||||
|
||||
// ترکیب تراکنشها
|
||||
const allTransactions = [...salesTransactions, ...settledTransactions];
|
||||
|
||||
// مرتبسازی بر اساس تاریخ (جدیدترین اول)
|
||||
allTransactions.sort((a, b) => {
|
||||
const dateA = new Date(a.date.split("/").reverse().join("/"));
|
||||
const dateB = new Date(b.date.split("/").reverse().join("/"));
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
});
|
||||
|
||||
setTransactions(allTransactions);
|
||||
|
||||
// محاسبه موجودی
|
||||
const balance = calculateBalance(totalPending, totalSettled);
|
||||
setTotalBalance(balance);
|
||||
|
||||
} catch (err) {
|
||||
console.log("error fetching all data:", err);
|
||||
toast.error("خطا در دریافت اطلاعات");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllData();
|
||||
}, []);
|
||||
|
||||
// فیلتر تراکنشها
|
||||
const filteredTransactions = transactions.filter(
|
||||
(t) => filterType === "all" || t.type === filterType
|
||||
);
|
||||
|
||||
// آمار کلی
|
||||
const stats = {
|
||||
sales: transactions.filter(t => t.type === "expense").reduce((sum, t) => sum + t.amount, 0),
|
||||
settled: transactions.filter(t => t.type === "withdraw").reduce((sum, t) => sum + t.amount, 0),
|
||||
pending: transactions.filter(t => t.type === "expense" && t.status === "pending").reduce((sum, t) => sum + t.amount, 0),
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex justify-center items-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-pink-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<div className="min-h-screen text-foreground">
|
||||
<div className="container mx-auto p-6 space-y-8">
|
||||
{/* کارتهای آمار - 3 کارت */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
|
||||
{/* کل فروش */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
>
|
||||
<Card className="text-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">کل فروش</CardTitle>
|
||||
<CardDescription className="dark:text-green-100 text-green-500">
|
||||
مجموع فروشهای موفق
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent >
|
||||
<div className="text-3xl font-bold dark:text-white text-neutral-700">
|
||||
{(stats.sales + stats.settled).toLocaleString()} تومان
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* تسویه شده */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<Card className="text-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">تسویه شده</CardTitle>
|
||||
<CardDescription className="dark:text-purple-100 text-purple-500">
|
||||
مبالغ پرداخت شده به حساب
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold dark:text-white text-neutral-700">
|
||||
{stats.settled.toLocaleString()} تومان
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* جداول تراکنشها */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-4">
|
||||
<CardTitle>گزارش تراکنشها</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={filterType}
|
||||
onValueChange={(value) =>
|
||||
setFilterType(value as "all" | "deposit" | "withdraw" | "expense")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="فیلتر بر اساس نوع" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه</SelectItem>
|
||||
<SelectItem value="expense">فروش</SelectItem>
|
||||
<SelectItem value="withdraw">پرداخت شده</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{filteredTransactions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
هیچ تراکنشی یافت نشد
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
{/* نمای دسکتاپ - جدول */}
|
||||
<div className="hidden md:block overflow-x-auto rounded-md border">
|
||||
<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>
|
||||
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredTransactions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8">
|
||||
هیچ تراکنشی یافت نشد
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredTransactions.map((transaction) => (
|
||||
<TableRow key={transaction.id} className="border-b">
|
||||
<TableCell className="font-medium text-right">
|
||||
{transaction.description}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span
|
||||
className={`font-bold ${
|
||||
transaction.type === "expense"
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{transaction.type === "expense" ? "+" : "-"}
|
||||
{transaction.amount.toLocaleString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{transaction.date}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${
|
||||
transaction.type === "expense"
|
||||
? "bg-yellow-500/10 text-yellow-600 border-yellow-500/30"
|
||||
: "bg-green-500/10 text-green-600 border-green-500/30"
|
||||
}`}
|
||||
>
|
||||
{transaction.type === "expense" ? "در انتظار تسویه" : "تسویه شده"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* نمای موبایل - کارتها */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{filteredTransactions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
هیچ تراکنشی یافت نشد
|
||||
</div>
|
||||
) : (
|
||||
filteredTransactions.map((transaction) => (
|
||||
<Card key={transaction.id} className="p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">{transaction.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{transaction.date}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={`font-bold text-lg ${
|
||||
transaction.type === "expense"
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{transaction.type === "expense" ? "+" : "-"}
|
||||
{transaction.amount.toLocaleString()}
|
||||
</p>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`mt-1 text-xs ${
|
||||
transaction.type === "expense"
|
||||
? "bg-yellow-500/10 text-yellow-600"
|
||||
: "bg-green-500/10 text-green-600"
|
||||
}`}
|
||||
>
|
||||
{transaction.type === "expense" ? "در انتظار تسویه" : "تسویه شده"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default WalletDashboard;
|
||||
370
src/app/settings/chats/[username]/[id]/page.tsx
Normal file
370
src/app/settings/chats/[username]/[id]/page.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { User } from "@/types/types";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
import { SOCKET_URL } from "@/components/main/BaseUrl";
|
||||
import MessageInput from "@/components/chat/MessageInput";
|
||||
import MultiImageModal from "@/components/chat/MultiImageModal";
|
||||
import ChatActionBar from "@/components/chat/ChatActionBar";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import ChatMessageList from "@/components/chat/ChatMessageList";
|
||||
import ChatHeader from "@/components/chat/ChatHeader";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
|
||||
import toast from "react-hot-toast";
|
||||
import { optimizeMediaFile } from "@/lib/media";
|
||||
import ForwardMessageModal from "@/components/chat/ForwardMessageModal";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { formatBubbleTime } from "@/lib/chat/formatMessageTime";
|
||||
|
||||
interface ITicketChatProps {
|
||||
params: Promise<{ id: string; username: string }>;
|
||||
}
|
||||
|
||||
function normalizeMessage(raw: ChatMessage & { data?: ChatMessage }): ChatMessage {
|
||||
const m = (raw as { data?: ChatMessage }).data ?? raw;
|
||||
return {
|
||||
...m,
|
||||
createdAt: formatBubbleTime(m.createdAt),
|
||||
replyTo: m.replyTo ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function TicketChat({ params }: ITicketChatProps) {
|
||||
const resolvedParams = React.use(params);
|
||||
const { request } = useAxios();
|
||||
const { username } = resolvedParams;
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
const [userTwoDetail, setUserTwoDetail] = useState<User>();
|
||||
const [pendingImages, setPendingImages] = useState<File[]>([]);
|
||||
const [showMultiModal, setShowMultiModal] = useState(false);
|
||||
const [pendingMessages, setPendingMessages] = useState<ChatMessage[]>([]);
|
||||
const [replyingTo, setReplyingTo] = useState<ChatMessage | null>(null);
|
||||
const [forwardMessage, setForwardMessage] = useState<ChatMessage | null>(null);
|
||||
const [selectedMessage, setSelectedMessage] = useState<ChatMessage | null>(null);
|
||||
const [actionMode, setActionMode] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const user = useUser();
|
||||
const [receiverId, setReceiverId] = useState("");
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const socketRef = useRef<ReturnType<typeof io> | null>(null);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const activeReplyRef = useRef<ChatMessage | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?._id || !receiverId) return;
|
||||
const socket = io(SOCKET_URL);
|
||||
socketRef.current = socket;
|
||||
socket.emit("joinChat", { userId: user._id, receiverId });
|
||||
socket.emit("joinUser", { userId: user._id });
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [user?._id, receiverId]);
|
||||
|
||||
const handleTyping = useCallback(() => {
|
||||
if (!socketRef.current || !user?._id || !receiverId) return;
|
||||
socketRef.current.emit("typing", {
|
||||
senderId: user._id,
|
||||
receiverId,
|
||||
});
|
||||
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
socketRef.current?.emit("stopTyping", {
|
||||
senderId: user._id,
|
||||
receiverId,
|
||||
});
|
||||
}, 2000);
|
||||
}, [user?._id, receiverId]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStates = async () => {
|
||||
try {
|
||||
const response = await request<{ user: User }>(
|
||||
"GET",
|
||||
`/users/get/web?user_name=${username}&_t=${Date.now()}`
|
||||
);
|
||||
setUserTwoDetail(response?.user);
|
||||
} catch (err) {
|
||||
console.error("Error fetching user:", err);
|
||||
}
|
||||
};
|
||||
fetchStates();
|
||||
}, [username, request]);
|
||||
|
||||
useEffect(() => {
|
||||
setReceiverId(userTwoDetail?._id || "");
|
||||
}, [user, userTwoDetail]);
|
||||
|
||||
const handleImageSelection = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = Array.from(e.target.files || []);
|
||||
if (!picked.length) return;
|
||||
const MAX_SIZE = 200 * 1024 * 1024;
|
||||
const valid = picked.filter((f) => {
|
||||
if (f.size > MAX_SIZE) {
|
||||
toast.error(`${f.name}: حجم بیش از ۲۰۰ مگابایت`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!valid.length) return;
|
||||
setPendingImages((prev) => [...prev, ...valid].slice(0, 10));
|
||||
setShowMultiModal(true);
|
||||
};
|
||||
|
||||
const handleVoiceUpload = (blob: Blob) => {
|
||||
const voiceFile = new File([blob], "voice-message.webm", {
|
||||
type: "audio/webm",
|
||||
});
|
||||
processSendMessage(voiceFile, "voice");
|
||||
};
|
||||
|
||||
const handleLocationShare = () => {
|
||||
if (!navigator.geolocation) {
|
||||
toast.error("مرورگر از موقعیت مکانی پشتیبانی نمیکند.");
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const locationContent = JSON.stringify({
|
||||
lat: pos.coords.latitude,
|
||||
lng: pos.coords.longitude,
|
||||
label: "موقعیت من",
|
||||
});
|
||||
processSendMessage(null, "location", locationContent);
|
||||
},
|
||||
() => toast.error("دسترسی به موقعیت مکانی داده نشد.")
|
||||
);
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
if (pendingImages.length > 0) {
|
||||
void sendAllImages();
|
||||
return;
|
||||
}
|
||||
processSendMessage(null);
|
||||
};
|
||||
|
||||
const sendAllImages = async () => {
|
||||
const batch = [...pendingImages];
|
||||
setShowMultiModal(false);
|
||||
setPendingImages([]);
|
||||
for (const file of batch) {
|
||||
await processSendMessage(file, "image");
|
||||
}
|
||||
};
|
||||
|
||||
const buildReplyPayload = (source: ChatMessage | null) => {
|
||||
if (!source) return undefined;
|
||||
return {
|
||||
_id: source._id,
|
||||
content: source.content?.slice(0, 120) || "پیام",
|
||||
senderName:
|
||||
source.senderId === user?._id
|
||||
? "شما"
|
||||
: `${userTwoDetail?.first_name || ""} ${userTwoDetail?.last_name || ""}`.trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const processSendMessage = async (
|
||||
fileToUpload: File | null,
|
||||
fileType?: ChatMessage["fileType"],
|
||||
contentOverride?: string
|
||||
) => {
|
||||
const replySource = activeReplyRef.current;
|
||||
let textContent = contentOverride ?? newMessage;
|
||||
if (textContent.trim() === "" && !fileToUpload) return;
|
||||
|
||||
const tempId = `temp-${Date.now()}-${Math.random()}`;
|
||||
const replyPayload = buildReplyPayload(replySource);
|
||||
|
||||
const optimisticMsg: ChatMessage = {
|
||||
_id: tempId,
|
||||
content: textContent,
|
||||
senderId: user?._id || "",
|
||||
createdAt: formatBubbleTime(new Date().toISOString()),
|
||||
status: "pending",
|
||||
file: fileToUpload ? URL.createObjectURL(fileToUpload) : "",
|
||||
fileType: fileType,
|
||||
replyTo: replyPayload,
|
||||
};
|
||||
|
||||
setPendingMessages((prev) => [...prev, optimisticMsg]);
|
||||
setNewMessage("");
|
||||
|
||||
let fileForUpload = fileToUpload;
|
||||
if (fileToUpload && fileType !== "location") {
|
||||
try {
|
||||
toast.loading("در حال بهینهسازی فایل…", { id: "media-opt" });
|
||||
fileForUpload = await optimizeMediaFile(fileToUpload);
|
||||
toast.dismiss("media-opt");
|
||||
} catch {
|
||||
toast.dismiss("media-opt");
|
||||
}
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("content", textContent);
|
||||
formData.append("receiverId", userTwoDetail?._id || "");
|
||||
formData.append("senderId", user?._id || "");
|
||||
if (replySource?._id && !replySource._id.startsWith("temp")) {
|
||||
formData.append("replyToId", replySource._id);
|
||||
}
|
||||
if (fileForUpload) {
|
||||
formData.append("file", fileForUpload);
|
||||
if (fileType) formData.append("fileType", fileType);
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint =
|
||||
fileToUpload && fileType !== "location" ? "/chat/file" : "/chat";
|
||||
|
||||
const response = await request<ChatMessage | { data: ChatMessage }>(
|
||||
"post",
|
||||
endpoint,
|
||||
formData,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||
);
|
||||
|
||||
const serverMsg = normalizeMessage(response as ChatMessage);
|
||||
setPendingMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m._id === tempId
|
||||
? {
|
||||
...serverMsg,
|
||||
status: "sent",
|
||||
replyTo: serverMsg.replyTo ?? replyPayload,
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
|
||||
setReplyingTo(null);
|
||||
activeReplyRef.current = null;
|
||||
} catch (error) {
|
||||
console.error("Error sending message:", error);
|
||||
setPendingMessages((prev) => prev.filter((m) => m._id !== tempId));
|
||||
toast.error("ارسال پیام ناموفق بود.");
|
||||
}
|
||||
};
|
||||
|
||||
const openActionFor = (msg: ChatMessage) => {
|
||||
setSelectedMessage(msg);
|
||||
setActionMode(true);
|
||||
};
|
||||
|
||||
const closeActionMode = () => {
|
||||
setActionMode(false);
|
||||
setSelectedMessage(null);
|
||||
};
|
||||
|
||||
const startReply = () => {
|
||||
if (!selectedMessage) return;
|
||||
setReplyingTo(selectedMessage);
|
||||
activeReplyRef.current = selectedMessage;
|
||||
closeActionMode();
|
||||
};
|
||||
|
||||
const startForward = () => {
|
||||
if (!selectedMessage) return;
|
||||
setForwardMessage(selectedMessage);
|
||||
closeActionMode();
|
||||
};
|
||||
|
||||
const handleBlockChange = () => {
|
||||
void request<{ user: User }>(
|
||||
"GET",
|
||||
`/users/get/web?user_name=${username}&_t=${Date.now()}`
|
||||
).then((res) => setUserTwoDetail(res?.user));
|
||||
};
|
||||
|
||||
return (
|
||||
<Container className="chat-page-bg flex h-[100dvh] max-h-[100dvh] flex-col overflow-hidden !px-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col px-2 sm:px-4">
|
||||
<ChatHeader
|
||||
user={userTwoDetail}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onBlockChange={handleBlockChange}
|
||||
/>
|
||||
<ChatMessageList
|
||||
pendingMessages={pendingMessages}
|
||||
userDetail={user as User}
|
||||
userTwoDetail={userTwoDetail}
|
||||
isTyping={isTyping}
|
||||
onTypingChange={setIsTyping}
|
||||
searchQuery={searchQuery}
|
||||
selectedMessageId={selectedMessage?._id}
|
||||
onBubbleClick={openActionFor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{actionMode ? (
|
||||
<ChatActionBar
|
||||
key="actions"
|
||||
onReply={startReply}
|
||||
onForward={startForward}
|
||||
onCancel={closeActionMode}
|
||||
/>
|
||||
) : (
|
||||
<MessageInput
|
||||
key="input"
|
||||
newMessage={newMessage}
|
||||
setNewMessage={setNewMessage}
|
||||
sendMessage={sendMessage}
|
||||
handleFileSelection={handleImageSelection}
|
||||
handleVoiceUpload={handleVoiceUpload}
|
||||
onLocationShare={handleLocationShare}
|
||||
onTyping={handleTyping}
|
||||
isChatThread
|
||||
blocked_you={userTwoDetail?.blocked_you}
|
||||
replyingTo={replyingTo}
|
||||
onCancelReply={() => {
|
||||
setReplyingTo(null);
|
||||
activeReplyRef.current = null;
|
||||
}}
|
||||
replyLabel={
|
||||
replyingTo
|
||||
? replyingTo.content?.slice(0, 80) || "پیام"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<MultiImageModal
|
||||
isOpen={showMultiModal}
|
||||
files={pendingImages}
|
||||
onRemove={(i) =>
|
||||
setPendingImages((prev) => {
|
||||
const next = prev.filter((_, idx) => idx !== i);
|
||||
if (next.length === 0) setShowMultiModal(false);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
onCancel={() => {
|
||||
setPendingImages([]);
|
||||
setShowMultiModal(false);
|
||||
}}
|
||||
onConfirm={sendAllImages}
|
||||
/>
|
||||
{forwardMessage && user?._id && (
|
||||
<ForwardMessageModal
|
||||
open={!!forwardMessage}
|
||||
onClose={() => setForwardMessage(null)}
|
||||
message={forwardMessage}
|
||||
currentUserId={user._id}
|
||||
currentReceiverId={receiverId}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default dynamic(() => Promise.resolve(TicketChat), { ssr: false });
|
||||
163
src/app/settings/chats/page.tsx
Normal file
163
src/app/settings/chats/page.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import { IMAGE_BASE_URL, SOCKET_URL } from "@/components/main/BaseUrl";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import UserDetails from "@/components/settings/UserDetails";
|
||||
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 { ChatListSkeleton } from "@/components/ui/ChatSkeletons";
|
||||
import { io } from "socket.io-client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export interface IMessage {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
user_name: string;
|
||||
is_verified: string;
|
||||
profile_image?: string;
|
||||
last_online?: string;
|
||||
_id: string;
|
||||
unread_messages_count?: number;
|
||||
is_blocked: boolean | null;
|
||||
blocked_you: boolean | null;
|
||||
last_message_date?: string | null;
|
||||
}
|
||||
|
||||
function Chats() {
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const user = useUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isFetchingNextPage, isLoading } = useInfiniteScroll({
|
||||
endpoint: "/messages",
|
||||
queryKey: ["messages", search],
|
||||
params: { search: search },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?._id) return;
|
||||
const socket = io(SOCKET_URL);
|
||||
socket.emit("joinUser", { userId: user._id });
|
||||
|
||||
socket.on("chatListUpdate", () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["messages"] });
|
||||
});
|
||||
|
||||
socket.on("newMessage", () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["messages"] });
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off("chatListUpdate");
|
||||
socket.off("newMessage");
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [user?._id, queryClient]);
|
||||
|
||||
const isEmpty =
|
||||
!isLoading &&
|
||||
(data?.pages.length === 0 ||
|
||||
(data?.pages[0]?.filteredUsersData?.length === 0 && !isFetchingNextPage));
|
||||
|
||||
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)}
|
||||
placeholder="جستجو"
|
||||
className="w-full"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="gentle-transition absolute left-3 top-[7px] active:scale-90"
|
||||
onClick={() => setSearch(searchText)}
|
||||
aria-label="جستجو"
|
||||
>
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
alt="جستجو"
|
||||
src="/images/icons/search-normal.svg"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 w-full">
|
||||
{isLoading ? (
|
||||
<ChatListSkeleton count={7} />
|
||||
) : isEmpty ? (
|
||||
<p className="py-8 text-center text-neutral-500">
|
||||
هنوز مکالمهای ندارید.
|
||||
</p>
|
||||
) : (
|
||||
data?.pages?.map((page, pageIndex) => (
|
||||
<React.Fragment key={pageIndex}>
|
||||
{page?.filteredUsersData?.map((item: IMessage) => (
|
||||
<Link
|
||||
href={`/settings/chats/${item?.user_name}/${item?._id}`}
|
||||
key={item?._id}
|
||||
className="gentle-transition mb-2 block w-full rounded-2xl border-b border-border-primary-light p-4 font-semibold hover:bg-black/[0.03] active:scale-[0.99] dark:hover:bg-white/[0.04]"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{item?.profile_image ? (
|
||||
<Image
|
||||
width={56}
|
||||
height={56}
|
||||
alt={item?.user_name}
|
||||
src={IMAGE_BASE_URL + item?.profile_image}
|
||||
className="h-14 w-14 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-14 w-14 rounded-full bg-neutral-200 dark:bg-neutral-700" />
|
||||
)}
|
||||
<div>
|
||||
<span className="text-sm">
|
||||
{item.first_name} {item.last_name}
|
||||
</span>
|
||||
<div className="mt-1 flex items-center gap-1 text-neutral-500">
|
||||
@{item?.user_name}
|
||||
{item?.is_verified === "verified" && (
|
||||
<Image
|
||||
width={18}
|
||||
height={18}
|
||||
alt="تایید"
|
||||
src="/images/icons/verify.svg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className="text-[10px] text-neutral-400">
|
||||
{item?.last_online}
|
||||
</span>
|
||||
{item?.unread_messages_count ? (
|
||||
<span className="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-[#2aabee] px-1.5 text-[10px] text-white">
|
||||
{item.unread_messages_count}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Chats;
|
||||
171
src/app/settings/edit/Authentication/page.tsx
Normal file
171
src/app/settings/edit/Authentication/page.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 } from "@/components/main/BaseUrl";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
|
||||
function NationalCart() {
|
||||
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];
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setNationalCartImg(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// حذف تصویر
|
||||
const removeImage = () => setNationalCartImg(null);
|
||||
|
||||
// ارسال فرم
|
||||
const uploadImage = async () => {
|
||||
if (!nationalCartImg) {
|
||||
toast.error("لطفا تصویر کارت ملی را انتخاب کنید");
|
||||
return;
|
||||
}
|
||||
if (!isCheckedOne) {
|
||||
toast.error("لطفا قوانین را مطالعه و تایید کنید");
|
||||
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("تصویر با موفقیت ارسال شد!");
|
||||
router.push("/settings/edit");
|
||||
} catch (err) {
|
||||
console.log("Upload error:", err);
|
||||
toast.error("خطا در ارسال تصویر!");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center address-page">
|
||||
<span className="text-xl font-bold">احراز هویت</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"
|
||||
>
|
||||
تایید قوانین و مقررات
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
className="mt-20"
|
||||
disabled={loading}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
export default NationalCart;
|
||||
303
src/app/settings/edit/License/page.tsx
Normal file
303
src/app/settings/edit/License/page.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import Image from "next/image";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
import Cookies from "js-cookie";
|
||||
import axios from "axios";
|
||||
|
||||
function LicensePage() {
|
||||
const user = useUser();
|
||||
const { loading } = useAxios();
|
||||
const userId = user?._id; // فرض بر وجود user.id به عنوان _id
|
||||
|
||||
const [avatar, setAvatar] = useState<string | null>(null);
|
||||
const [license, setLicense] = useState<any>(null);
|
||||
const [loadingUpload, setLoadingUpload] = useState(false);
|
||||
|
||||
// دریافت اطلاعات License هنگام لود صفحه
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
fetchLicense();
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
|
||||
const fetchLicense = async () => {
|
||||
try {
|
||||
// گرفتن توکن از کوکی
|
||||
const token = Cookies.get("token");
|
||||
if (!token) {
|
||||
toast.error("توکن یافت نشد، لطفاً دوباره وارد شوید");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await axios.get(`${BASE_URL}/users/License/${userId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
setLicense(response.data);
|
||||
|
||||
if (response.data.licenseImg) {
|
||||
setAvatar(response.data.licenseImg);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
console.error(error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
// Compress image and convert to Base64
|
||||
const compressImage = (
|
||||
file: File,
|
||||
maxWidth: number,
|
||||
maxHeight: number,
|
||||
quality: number
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new window.Image();
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
img.src = e.target?.result as string;
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject(new Error("Canvas context not supported"));
|
||||
return;
|
||||
}
|
||||
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
if (width > height) {
|
||||
if (width > maxWidth) {
|
||||
height = Math.round((height * maxWidth) / width);
|
||||
width = maxWidth;
|
||||
}
|
||||
} else {
|
||||
if (height > maxHeight) {
|
||||
width = Math.round((width * maxHeight) / height);
|
||||
height = maxHeight;
|
||||
}
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
resolve(canvas.toDataURL("image/jpeg", quality));
|
||||
};
|
||||
img.onerror = () => reject(new Error("Failed to load image"));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
// Select and compress image
|
||||
const selectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
const file = event.target.files[0];
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("فقط فایلهای تصویری مجاز هستند");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoadingUpload(true);
|
||||
const compressedBase64 = await compressImage(file, 800, 800, 0.7);
|
||||
setAvatar(compressedBase64);
|
||||
} catch (error) {
|
||||
toast.error("خطا در بارگذاری تصویر");
|
||||
console.error("Image compression error:", error);
|
||||
} finally {
|
||||
setLoadingUpload(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Remove image (local)
|
||||
const removeImage = () => {
|
||||
setAvatar(null);
|
||||
const fileInput = document.getElementById("fileInput") as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = "";
|
||||
};
|
||||
|
||||
const upload = async () => {
|
||||
if (!avatar) {
|
||||
toast.error("لطفاً یک تصویر انتخاب کنید");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingUpload(true);
|
||||
|
||||
// گرفتن توکن از کوکی
|
||||
const token = Cookies.get("token");
|
||||
if (!token) {
|
||||
toast.error("توکن یافت نشد، لطفاً دوباره وارد شوید");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/users/License`,
|
||||
{ image: avatar, userId },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 201) {
|
||||
toast.success("تصویر با موفقیت آپلود شد");
|
||||
setLicense(response.data.license);
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("خطا در آپلود تصویر");
|
||||
console.error( error);
|
||||
} finally {
|
||||
setLoadingUpload(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center justify-center address-page">
|
||||
<span className="text-xl mb-9 font-bold">بارگذاری مجوز</span>
|
||||
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<Image
|
||||
className="rounded-2xl object-cover"
|
||||
width={130}
|
||||
height={130}
|
||||
alt={user?.user_name || "user profile"}
|
||||
src={
|
||||
user?.profile_image
|
||||
? `${IMAGE_BASE_URL}${user.profile_image}`
|
||||
: "/images/placeholder.png"
|
||||
}
|
||||
unoptimized={true}
|
||||
/>
|
||||
<div className="flex flex-col justify-center items-center gap-1 mt-1">
|
||||
<span>
|
||||
{user?.first_name && user?.first_name + " " + user?.last_name}
|
||||
</span>
|
||||
<div className="flex flex-col justify-center items-center gap-1 mt-1">
|
||||
{user?.user_name}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small className="font-bold mt-10 mb-4 text-center">
|
||||
برای ثبت درخواست نیازمند احراز هویت شما هستیم
|
||||
</small>
|
||||
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{loading || loadingUpload ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col items-center mb-4">
|
||||
<div className="relative w-[250px] h-[250px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-10">
|
||||
{avatar ? (
|
||||
<>
|
||||
<img
|
||||
src={avatar}
|
||||
alt="License Image"
|
||||
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 license 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 license icon"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
id="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
</div>
|
||||
{license && (
|
||||
<div className="flex flex-col items-center mt-4 text-green-700">
|
||||
<p>وضعیت تأیید: {license.Confirmation ? "تأیید شده" : "در انتظار تأیید"}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<small className="font-bold mt-10 mb-4 text-center">
|
||||
برای دریافت تیک طلایی مجوز خود را بارگذاری کنید
|
||||
</small>
|
||||
|
||||
<div className="flex">
|
||||
<div>
|
||||
<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={upload} className="mt-20">
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
<small className="font-bold mt-10 mb-1 text-center">
|
||||
زمان تایید مدارک 5 دقیقه تا 1 ساعت در ساعات اداری و
|
||||
</small>
|
||||
<small className="font-bold text-center">
|
||||
3 تا 8 ساعت در ساعات غیر اداری
|
||||
</small>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicensePage;
|
||||
158
src/app/settings/edit/avatar/page.tsx
Normal file
158
src/app/settings/edit/avatar/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import Container from "@/components/elements/Container";
|
||||
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";
|
||||
|
||||
function AvatarPage() {
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
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];
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setAvatar(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Upload image
|
||||
const uploadImage = async () => {
|
||||
if (!avatar) {
|
||||
toast.error("لطفا تصویر پروفایل را انتخاب کنید");
|
||||
} else {
|
||||
setLoadingUpload(true);
|
||||
|
||||
const formData = new FormData();
|
||||
// اطمینان حاصل کنید که داده تصویر درست به سرور ارسال میشود
|
||||
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",
|
||||
},
|
||||
});
|
||||
setLoadingUpload(false);
|
||||
router.push("/settings/edit");
|
||||
} else {
|
||||
setLoadingUpload(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("Upload error:", err);
|
||||
setLoadingUpload(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Remove image
|
||||
const removeImage = () => {
|
||||
setAvatar(null);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
{loading || loadingUpload ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col items-center mb-4">
|
||||
<span className="text-xl font-bold">تصویر پروفایل</span>
|
||||
<div className="relative w-[250px] h-[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 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}
|
||||
/>
|
||||
</div>
|
||||
<AuthNextButton className="mt-2">
|
||||
<label
|
||||
className="cursor-pointer w-full h-full"
|
||||
htmlFor="fileInput"
|
||||
>
|
||||
ویرایش تصویر
|
||||
</label>
|
||||
</AuthNextButton>
|
||||
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
className="mt-20"
|
||||
disabled={loading}
|
||||
>
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default AvatarPage;
|
||||
87
src/app/settings/edit/bio/page.tsx
Normal file
87
src/app/settings/edit/bio/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useEffect } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
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 الزامی است"),
|
||||
});
|
||||
|
||||
function PublicRelations() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const user = useUser();
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
bio: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
await request("PATCH", "/verify/conversation", {
|
||||
conversation_projects: true,
|
||||
bio: values.bio,
|
||||
});
|
||||
router.push("/settings/edit");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "خطای نامشخصی رخ داد.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
formik.setValues({
|
||||
bio: user?.bio ?? "",
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">بیو</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}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicRelations;
|
||||
171
src/app/settings/edit/colors/page.tsx
Normal file
171
src/app/settings/edit/colors/page.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
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(" رنگ چشم الزامی است"),
|
||||
});
|
||||
|
||||
function Colors() {
|
||||
const [selectedButton, setSelectedButton] = useState<number>(1); // Default: 1 for "قد"
|
||||
const [selectedHairImage, setSelectedHairImage] = useState<string>("");
|
||||
const [selectedEyeImage, setSelectedEyeImage] = useState<string>("");
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const handleCheck = async () => {
|
||||
try {
|
||||
await schema.validate({ selectedHairImage, selectedEyeImage });
|
||||
await sendSizesHandler();
|
||||
} catch (validationError: any) {
|
||||
toast.error(validationError.message, {
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedHairImage(user?.hair_color || "");
|
||||
setSelectedEyeImage(user?.eye_color || "");
|
||||
}, [user]);
|
||||
|
||||
const sendSizesHandler = async () => {
|
||||
try {
|
||||
const data = {
|
||||
mobile,
|
||||
hair_color: selectedHairImage,
|
||||
eye_color: selectedEyeImage,
|
||||
};
|
||||
await request("PATCH", "/verify/colors", data);
|
||||
router.push("/settings/edit");
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtonPress = (buttonIndex: number) => {
|
||||
setSelectedButton(buttonIndex);
|
||||
};
|
||||
|
||||
// تصاویر رنگ چشم و رنگ مو
|
||||
const eyeImages = Array.from(
|
||||
{ length: 26 },
|
||||
(_, i) => `/images/eyes/${String(i + 1).padStart(2, "0")}.png`
|
||||
);
|
||||
const hairImages = Array.from(
|
||||
{ length: 85 },
|
||||
(_, i) => `/images/hairs/${String(i + 1).padStart(2, "0")}.png`
|
||||
);
|
||||
|
||||
// تابع برای ذخیره نام فایل انتخاب شده
|
||||
const handleImageSelect = (image: string, type: "eye" | "hair") => {
|
||||
const fileName = image.split("/").pop(); // استخراج فقط نام فایل
|
||||
if (type === "eye") {
|
||||
setSelectedEyeImage(fileName || "");
|
||||
} else if (type === "hair") {
|
||||
setSelectedHairImage(fileName || "");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">سایز</span>
|
||||
|
||||
<p className="mt-8 text-center text-sm font-bold">مشخصات ظاهری</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
|
||||
? "bg-[#FC8EAC] border-[#FC8EAC]"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(1)}
|
||||
>
|
||||
رنگ چشم
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "bg-[#FC8EAC] border-[#FC8EAC]"
|
||||
: "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>
|
||||
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
className="mt-10"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Colors;
|
||||
101
src/app/settings/edit/cooperation-type/page.tsx
Normal file
101
src/app/settings/edit/cooperation-type/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useEffect } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
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("انتخاب نوع همکاری الزامی است"),
|
||||
});
|
||||
|
||||
function CooperationType() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const user = useUser();
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedButton: null as boolean | null,
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
await request("PATCH", "/verify/cooperation_type", {
|
||||
cooperation_abroad: values.selectedButton,
|
||||
cooperation_type: "all",
|
||||
});
|
||||
router.push("/settings/edit");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "خطای نامشخصی رخ داد.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
formik.setValues({ selectedButton: user?.cooperation_abroad ?? null });
|
||||
}, [user?.cooperation_abroad]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">نوع همکاری</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
|
||||
? "!bg-[#FC8EAC] !border-[#FC8EAC] !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
|
||||
? "!bg-[#FC8EAC] !border-[#FC8EAC] !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>
|
||||
)}
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default CooperationType;
|
||||
146
src/app/settings/edit/expertise/page.tsx
Normal file
146
src/app/settings/edit/expertise/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } 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";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
subExpertise: yup
|
||||
.array()
|
||||
.of(yup.string().required("حداقل یک زیرمهارت را انتخاب کنید"))
|
||||
.min(1, "حداقل یک زیرمهارت را انتخاب کنید"),
|
||||
expertise: yup.string().required("انتخاب نوع تخصص الزامی است"),
|
||||
});
|
||||
|
||||
function Expertise() {
|
||||
const [expertise, setExpertise] = useState<string>("");
|
||||
const [expertiseList, setExpertiseList] = useState<IExpertise[] | null>(null);
|
||||
const [subExpertise, setSubExpertise] = useState<string[]>([]);
|
||||
const user = useUser();
|
||||
useEffect(() => {
|
||||
setExpertise(user?.expertise || "");
|
||||
setSubExpertise(user?.sub_expertise || []);
|
||||
}, [user]);
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
// Fetch expertise list
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await request<{ expertises: IExpertise[] }>(
|
||||
"GET",
|
||||
"/expertise"
|
||||
);
|
||||
setExpertiseList(response?.expertises);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleCheck = async () => {
|
||||
try {
|
||||
await schema.validate({ expertise, subExpertise });
|
||||
await sendExpertiseTypeHandler();
|
||||
} catch (validationError: any) {
|
||||
toast.error(validationError.message, {
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const sendExpertiseTypeHandler = async () => {
|
||||
try {
|
||||
const data = {
|
||||
mobile,
|
||||
expertise,
|
||||
sub_expertise: subExpertise,
|
||||
};
|
||||
await request("PATCH", "/verify/expertise", data);
|
||||
localStorage.setItem("expertise", expertise);
|
||||
router.push("/settings/edit");
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "خطای نامشخصی رخ داد.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtonPress = (expertise: string) => {
|
||||
setExpertise(expertise);
|
||||
setSubExpertise([]);
|
||||
};
|
||||
|
||||
const handleSubButtonPress = (sub_expertise: string) => {
|
||||
const updatedSubExpertise = subExpertise.includes(sub_expertise)
|
||||
? subExpertise.filter((item) => item !== sub_expertise)
|
||||
: [...subExpertise, sub_expertise];
|
||||
setSubExpertise(updatedSubExpertise);
|
||||
};
|
||||
|
||||
const selectedExpertise = expertiseList?.find(
|
||||
(item) => item.expertise === expertise
|
||||
);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">تخصص</span>
|
||||
|
||||
<p className="mt-8 text-center">در چه زمینه ای تخصص دارید؟</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 mt-4 max-w-[330px]">
|
||||
{expertiseList?.map((item) => (
|
||||
<button
|
||||
key={item._id}
|
||||
className={`p-1 min-w-24 rounded-3xl border-2 overflow-hidden text-xs ${
|
||||
expertise === item.expertise
|
||||
? "bg-yellow-500 text-white border-yellow-500"
|
||||
: "bg-white text-black border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleButtonPress(item.expertise)}
|
||||
>
|
||||
{item.expertise}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{selectedExpertise?.sub_expertise.map((item) => (
|
||||
<button
|
||||
key={item._id}
|
||||
className={`p-1 min-w-24 rounded-3xl border-2 overflow-hidden text-xs ${
|
||||
subExpertise.includes(item.name)
|
||||
? "bg-pink-500 text-white border-pink-500"
|
||||
: "bg-white text-black border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleSubButtonPress(item.name)}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
className="mt-20"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Expertise;
|
||||
271
src/app/settings/edit/location/page.tsx
Normal file
271
src/app/settings/edit/location/page.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
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";
|
||||
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(" انتخاب استان الزامی است"),
|
||||
});
|
||||
|
||||
function LocationPage() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const user = useUser();
|
||||
|
||||
const [isCheckedOne, setIsCheckedOne] = useState<boolean | null>(false);
|
||||
const [allStates, setAllStates] = useState<IProvince[] | null>(null);
|
||||
const [cities, setCities] = useState<ICity[] | null>(null);
|
||||
const [selectedLocation, setSelectedLocation] = useState<{
|
||||
lat: number;
|
||||
lng: number;
|
||||
} | null>(null);
|
||||
|
||||
const fetchStates = async () => {
|
||||
try {
|
||||
const response = await request<{ provinces: IProvince[] }>(
|
||||
"GET",
|
||||
"/provinces"
|
||||
);
|
||||
setAllStates(response?.provinces || null);
|
||||
// console.log(response?.provinces);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch cities when a province is selected
|
||||
const fetchCities = async (provinceId: string) => {
|
||||
try {
|
||||
const response = await request<{ cities: ICity[] }>(
|
||||
"GET",
|
||||
`/cities/${provinceId}`
|
||||
);
|
||||
setCities(response?.cities || []);
|
||||
|
||||
|
||||
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStates();
|
||||
}, []);
|
||||
|
||||
const toggleCheckBox = () => {
|
||||
setIsCheckedOne(!isCheckedOne);
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
markerCoordinate: [] as number[],
|
||||
address: "",
|
||||
cityId: "",
|
||||
stateId: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
await request("PATCH", "/verify/address", {
|
||||
address: values.address,
|
||||
province_id: values.stateId,
|
||||
city_id: values.cityId,
|
||||
lat: values.markerCoordinate[0],
|
||||
lng: values.markerCoordinate[1],
|
||||
show_location: isCheckedOne,
|
||||
});
|
||||
router.push("/settings/edit");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "خطای نامشخصی رخ داد.");
|
||||
} 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
|
||||
};
|
||||
|
||||
const handleMapClick = (event: any) => {
|
||||
const { lngLat } = event;
|
||||
setSelectedLocation({
|
||||
lat: lngLat.lat,
|
||||
lng: lngLat.lng,
|
||||
});
|
||||
formik.setFieldValue("markerCoordinate", [lngLat.lng, lngLat.lat]);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
formik.setValues({
|
||||
markerCoordinate:
|
||||
user.lat && user.lng ? [Number(user.lat), Number(user.lng)] : [],
|
||||
address: user.address || "",
|
||||
cityId: user?.city?.id ? String(user.city.id) : "",
|
||||
stateId: user?.province?.id ? String(user.province.id) : "",
|
||||
});
|
||||
|
||||
if (user.lat && user.lng) {
|
||||
setSelectedLocation({
|
||||
lat: Number(user?.lng),
|
||||
lng: Number(user?.lat),
|
||||
});
|
||||
}
|
||||
|
||||
if (user?.province?.id) {
|
||||
fetchCities(user?.province?.id.toString());
|
||||
}
|
||||
if (user?.show_location !== undefined) {
|
||||
setIsCheckedOne(user?.show_location);
|
||||
}
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center justify-center address-page ">
|
||||
<span className="text-xl font-bold">لوکیشن</span>
|
||||
<div className="mt-5"></div>
|
||||
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
مشخص کنید در کدام شهر قادر به انجام فعالیت هستید
|
||||
</small>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
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>
|
||||
)}
|
||||
|
||||
<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: Number(user?.lat) || 51.375433528216654,
|
||||
latitude: Number(user?.lng) || 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"
|
||||
onChange={toggleCheckBox}
|
||||
checked={isCheckedOne || false}
|
||||
/>
|
||||
<span className="text-xs font-bold">
|
||||
اطلاعات لوکیشن شما برای همه قابل نمایش باشد
|
||||
</span>
|
||||
</label>
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default LocationPage;
|
||||
82
src/app/settings/edit/page.tsx
Normal file
82
src/app/settings/edit/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import UserDetails from "@/components/settings/UserDetails";
|
||||
import { editEmployerNavLinks, editUserNavLinks } from "@/constants";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
function EditPage() {
|
||||
const [usertype, setUsertype] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setUsertype(localStorage.getItem("usertype"));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [isRegister, setIsRegister] = useState<string | undefined>("false");
|
||||
const user = useUser();
|
||||
useEffect(() => {
|
||||
setIsRegister(user?.is_Register);
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<PageTitle>ویرایش</PageTitle>
|
||||
<div className="px-4">
|
||||
<UserDetails />
|
||||
<div className="flex flex-col gap-5 md:gap-8 my-10 text-sm font-semibold">
|
||||
{usertype
|
||||
? usertype === "user"
|
||||
? editUserNavLinks?.map((item) => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
})
|
||||
: editEmployerNavLinks?.map((item) => {
|
||||
return (
|
||||
<Link
|
||||
className="flex items-center gap-2"
|
||||
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>
|
||||
);
|
||||
})
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditPage;
|
||||
114
src/app/settings/edit/password/page.tsx
Normal file
114
src/app/settings/edit/password/page.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import React 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 { useUser } from "@/hooks/useUser";
|
||||
|
||||
// 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("تکرار کلمه عبور الزامی است"),
|
||||
});
|
||||
|
||||
function PasswordPage() {
|
||||
const router = useRouter();
|
||||
const user = useUser();
|
||||
const { request, loading } = useAxios();
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
(await request("PATCH", "/login/change_password", {
|
||||
mobile: user?.mobile,
|
||||
new_password: values.password,
|
||||
})) as IVerifyOtp;
|
||||
router.push("/settings/edit");
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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="password"
|
||||
type="password"
|
||||
placeholder="کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
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>
|
||||
)}
|
||||
|
||||
<AuthInput
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
placeholder="تکرار کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
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" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "تایید کلمه عبور"}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordPage;
|
||||
135
src/app/settings/edit/public-relations/page.tsx
Normal file
135
src/app/settings/edit/public-relations/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useEffect } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
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("لطفا یکی از گزینه ها را انتخاب کنید"),
|
||||
});
|
||||
|
||||
function PublicRelations() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const user = useUser();
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedButton: null as boolean | null,
|
||||
bio: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
await request("PATCH", "/verify/conversation", {
|
||||
conversation_projects: values.selectedButton,
|
||||
bio: values.bio,
|
||||
});
|
||||
router.push("/settings/edit");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "خطای نامشخصی رخ داد.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
formik.setValues({
|
||||
selectedButton: user?.conversation_projects ?? null,
|
||||
bio: user?.bio ?? "",
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">روابط عمومی</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
|
||||
? "!bg-[#FC8EAC] !border-[#FC8EAC] !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
|
||||
? "!bg-[#FC8EAC] !border-[#FC8EAC] !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}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicRelations;
|
||||
134
src/app/settings/edit/services/page.tsx
Normal file
134
src/app/settings/edit/services/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Service, User } from "@/types/types";
|
||||
import ServiceItem from "@/components/main/Services/ServiceItem";
|
||||
import AddServiceModal from "@/components/main/Services/AddServiceModal";
|
||||
import Container from "@/components/elements/Container";
|
||||
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";
|
||||
|
||||
const ServicesPage: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [user, setUser] = useState<User>();
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const response = await request<{ user: User }>(
|
||||
"GET",
|
||||
"/profile?services=1"
|
||||
);
|
||||
setUser(response?.user);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user:", error);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
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)
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (services.length === 0) {
|
||||
toast.error("لطفاً حداقل یک خدمت وارد کنید.");
|
||||
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
|
||||
);
|
||||
} else if (service.image && blob) {
|
||||
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",
|
||||
},
|
||||
});
|
||||
router.push("/settings/edit");
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">خدمات</span>
|
||||
|
||||
<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}
|
||||
disabled={loading}
|
||||
className="mb-4 w-32"
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServicesPage;
|
||||
99
src/app/settings/edit/shaba/page.tsx
Normal file
99
src/app/settings/edit/shaba/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useEffect } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
shaba: yup
|
||||
.string()
|
||||
.matches(/^(?=.{24}$)[0-9]*$/, "شماره شبا باید ۲۴ رقم باشد")
|
||||
.required("شماره شبا الزامی است"),
|
||||
});
|
||||
|
||||
function AuthPage() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const user = useUser();
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
shaba: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
await request("PATCH", "/verify/shaba", {
|
||||
shaba: values.shaba,
|
||||
});
|
||||
router.push("/settings/edit");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "خطای نامشخصی رخ داد.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.shaba) {
|
||||
formik.setValues({ shaba: user.shaba });
|
||||
}
|
||||
}, [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">شماره شبا</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" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthPage;
|
||||
187
src/app/settings/edit/sizes/page.tsx
Normal file
187
src/app/settings/edit/sizes/page.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
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("وارد کردن قد الزامی است"),
|
||||
});
|
||||
|
||||
const sizes = ["34", "36", "38", "40", "42", "44", "46", "48", "50"];
|
||||
|
||||
function Sizes() {
|
||||
const [height, setHeight] = useState<string>("");
|
||||
const [weight, setWeight] = useState<string>("");
|
||||
const [size, setSize] = useState<string | null>(null);
|
||||
const user = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
setHeight(user?.height || "");
|
||||
setWeight(user?.weight || "");
|
||||
setSize(user?.size || "");
|
||||
}, [user]);
|
||||
|
||||
const [selectedButton, setSelectedButton] = useState<number>(1); // Default: 1 for "قد"
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
const handleCheck = async () => {
|
||||
try {
|
||||
await schema.validate({ height, weight, size });
|
||||
await sendSizesHandler();
|
||||
} catch (validationError: any) {
|
||||
toast.error(validationError.message, {
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const sendSizesHandler = async () => {
|
||||
try {
|
||||
const data = {
|
||||
mobile,
|
||||
height,
|
||||
weight,
|
||||
size,
|
||||
};
|
||||
await request("PATCH", "/verify/sizes", data);
|
||||
router.push("/settings/edit");
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "خطای نامشخصی رخ داد.");
|
||||
}
|
||||
};
|
||||
|
||||
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">سایز</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
|
||||
? "bg-[#FC8EAC] border-[#FC8EAC]"
|
||||
: "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
|
||||
? "bg-[#FC8EAC] border-[#FC8EAC]"
|
||||
: "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
|
||||
? "bg-[#FC8EAC] border-[#FC8EAC]"
|
||||
: "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
|
||||
? "bg-[#FC8EAC] text-white border-[#FC8EAC]"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
}`}
|
||||
onClick={() => handleSizeSelection(sizeOption)}
|
||||
>
|
||||
{sizeOption}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
className="mt-10"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sizes;
|
||||
84
src/app/settings/edit/username/page.tsx
Normal file
84
src/app/settings/edit/username/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
import React, { useEffect } from "react";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import Container from "@/components/elements/Container";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
username: yup.string().required("نام کاربری الزامی است"),
|
||||
});
|
||||
|
||||
function UsernamePage() {
|
||||
const router = useRouter();
|
||||
const user = useUser();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
username: user?.user_name || "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
await request("PATCH", "/register/username", {
|
||||
user_name: values.username,
|
||||
});
|
||||
localStorage.setItem("username", values.username);
|
||||
router.push("/settings/edit");
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// آپدیت مقدار username در صورتی که user.user_name مقدار داشته باشد
|
||||
useEffect(() => {
|
||||
if (user?.user_name) {
|
||||
formik.setValues({ username: user.user_name });
|
||||
}
|
||||
}, [user?.user_name]);
|
||||
|
||||
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="نام کاربری خود را وارد کنید"
|
||||
className={`border ${
|
||||
formik.touched.username && formik.errors.username
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.username}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.username && formik.errors.username && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.username}
|
||||
</small>
|
||||
)}
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default UsernamePage;
|
||||
157
src/app/settings/financial/page.tsx
Normal file
157
src/app/settings/financial/page.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import UserDetails from "@/components/settings/UserDetails";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
|
||||
export interface IFinancial {
|
||||
_id: string;
|
||||
type: string;
|
||||
project_title?: string; // اختیاری
|
||||
project_id?: string;
|
||||
createdAt: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function Financial() {
|
||||
|
||||
|
||||
const {
|
||||
data,
|
||||
|
||||
isFetchingNextPage,
|
||||
|
||||
containerRef,
|
||||
} = useInfiniteScroll({
|
||||
endpoint: "/financial",
|
||||
queryKey: ["financial"],
|
||||
limit: 15,
|
||||
});
|
||||
|
||||
// فلت کردن تمام تراکنشها
|
||||
const allTransactions = data?.pages.flatMap((page) => page.financial || []) ?? [];
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ترجمه نوع تراکنش
|
||||
const getTransactionType = (type: string) => {
|
||||
switch (type) {
|
||||
case "offer":
|
||||
return "درخواست همکاری";
|
||||
case "advertising":
|
||||
return "تبلیغات";
|
||||
default:
|
||||
return type || "سایر";
|
||||
}
|
||||
};
|
||||
|
||||
// ترجمه وضعیت
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case "successful":
|
||||
return "پرداخت شده";
|
||||
case "failed":
|
||||
return "پرداخت ناموفق";
|
||||
default:
|
||||
return "در انتظار پرداخت";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case "successful":
|
||||
return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400";
|
||||
case "failed":
|
||||
return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400";
|
||||
default:
|
||||
return "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return dateStr;
|
||||
|
||||
const persianDate = new Intl.DateTimeFormat("fa-IR", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
|
||||
return persianDate; // خروجی مثلاً: ۱۴۰۴/۱۱/۲۸، ۱۴:۳۳
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<PageTitle>مالی</PageTitle>
|
||||
|
||||
<div className="px-4 text-xs md:text-sm">
|
||||
<UserDetails />
|
||||
|
||||
<div className="w-full mt-10 min-h-[50vh]">
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="space-y-4 max-h-[65vh] overflow-y-auto scrollbar-thin scrollbar-thumb-neutral-300 dark:scrollbar-thumb-neutral-600"
|
||||
>
|
||||
{allTransactions[0]?.docs?.map((item: IFinancial) => (
|
||||
<div
|
||||
key={item._id}
|
||||
className="p-5 border rounded-3xl border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
{/* ردیف اول: عنوان + تاریخ */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-3">
|
||||
<div className="font-medium text-base md:text-lg text-neutral-900 dark:text-white flex items-center gap-2">
|
||||
{getTransactionType(item.type)}
|
||||
{item.project_title && (
|
||||
<span className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
({item.project_title})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 whitespace-nowrap">
|
||||
{formatDate(item.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ردیف دوم: مبلغ + وضعیت */}
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||
<div className="font-semibold text-lg text-neutral-900 dark:text-white">
|
||||
مبلغ پرداختی: {Number(item.amount).toLocaleString("fa-IR")} تومان
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-neutral-600 dark:text-neutral-300">وضعیت:</span>
|
||||
<span
|
||||
className={`font-medium px-4 py-1.5 rounded-full text-sm ${getStatusClass(
|
||||
item.status
|
||||
)}`}
|
||||
>
|
||||
{getStatusText(item.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isFetchingNextPage && (
|
||||
<div className="py-10 flex justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
5
src/app/settings/layout.tsx
Normal file
5
src/app/settings/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import SettingsShell from "@/components/settings/SettingsShell";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <SettingsShell>{children}</SettingsShell>;
|
||||
}
|
||||
127
src/app/settings/my-billboards/[id]/[title]/page.tsx
Normal file
127
src/app/settings/my-billboards/[id]/[title]/page.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import React from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import Container from "@/components/elements/Container";
|
||||
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 MyBillboardStatusActions from "@/components/settings/my-billboards/MyBillboardStatusActions";
|
||||
|
||||
interface IBillboardProps {
|
||||
params: Promise<{ id: string; title: string }>;
|
||||
}
|
||||
|
||||
async function BillboardPage({ params }: IBillboardProps) {
|
||||
const { id } = await params;
|
||||
const token = (await cookies()).get("token")?.value || "";
|
||||
|
||||
const response = await fetch(`${BASE_URL}/advertising/get/web/${id}`, {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
});
|
||||
|
||||
// const billboard = (await response.json()).billboard as Billboard;
|
||||
const data = await response.json();
|
||||
const billboard = data.advertising as IAdvertising;
|
||||
const rate = data.rate as IRate;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<a
|
||||
href={`/billboards/profile/${billboard?.creatorId}/${billboard?.title}`}
|
||||
>
|
||||
<h1 className="font-bold md:text-xl text-lg text-center mt-10 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}/${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 gap-4 mt-4">
|
||||
<span>استان: {billboard?.province.name}</span>
|
||||
<h3>شهر: {billboard?.city.name}</h3>
|
||||
<h4>محله: {billboard?.neighbourhood}</h4>
|
||||
</div>
|
||||
<div className="flex gap-4 mt-2">
|
||||
<h5>آدرس: {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}
|
||||
noDirection={true}
|
||||
/>
|
||||
<MyBillboardStatusActions
|
||||
status={billboard?.status}
|
||||
reject_reason={billboard?.reject_reason}
|
||||
_id={billboard?._id}
|
||||
type={billboard?.type}
|
||||
showDiscount={billboard?.showDiscount}
|
||||
title={billboard?.title}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default BillboardPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { BillboardFormProvider } from "@/contexts/BillboardFormContext";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<BillboardFormProvider>
|
||||
<section>{children}</section>
|
||||
</BillboardFormProvider>
|
||||
);
|
||||
}
|
||||
68
src/app/settings/my-billboards/edit/[id]/[title]/page.tsx
Normal file
68
src/app/settings/my-billboards/edit/[id]/[title]/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import MultiStepForm from "@/components/billboards/NewBillboard/MultiStepForm";
|
||||
import Container from "@/components/elements/Container";
|
||||
import { useBillboardForm } from "@/contexts/BillboardFormContext";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IAdvertising } from "@/types/types";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface IEditProps {
|
||||
params: Promise<{ id: string; username: string }>;
|
||||
}
|
||||
|
||||
export default function EditBillboardWrapper({ params }: IEditProps) {
|
||||
const { request } = useAxios();
|
||||
const { updateForm, setIsEditing } = useBillboardForm(); // گرفتن تابع آپدیت فرم
|
||||
|
||||
const resolvedParams = React.use(params);
|
||||
const { id } = resolvedParams;
|
||||
const [fetched, setFetched] = useState<boolean>(false);
|
||||
useEffect(() => {
|
||||
setIsEditing(true); // ✅ اعلام اینکه در حال ویرایش هستیم
|
||||
|
||||
const fetchAd = async () => {
|
||||
if (!id) return;
|
||||
const response = await request<{ advertising: IAdvertising }>(
|
||||
"GET",
|
||||
`/advertising/get/web/${id}`
|
||||
);
|
||||
|
||||
if (response?.advertising) {
|
||||
const res = response.advertising;
|
||||
updateForm({
|
||||
stateId: String(res.province?.id) || "",
|
||||
cityId: String(res.city?.id) || "",
|
||||
categoryId: res.category || "",
|
||||
adsTitle: res.title || "",
|
||||
description: res.description || "",
|
||||
neighbourhood: res.neighbourhood || "",
|
||||
address: res.address || "",
|
||||
markerCoordinate: res.lat && res.lng ? [res.lat, res.lng] : [],
|
||||
images: res.images || [],
|
||||
services: res.services || [],
|
||||
selectedFeatures: res.features || [],
|
||||
landline: res.contactInfo?.phone || "",
|
||||
mobile: res?.contactInfo?.mobile || "",
|
||||
telegram: res?.contactInfo?.telegramLink || "",
|
||||
whatsapp: res?.contactInfo?.whatsappNumber || "",
|
||||
instagram: res?.contactInfo?.instagramLink || "",
|
||||
saveData: res?.contactInfo?.saveInfoForNextAds || false,
|
||||
selectedType: res.type || "",
|
||||
showDiscount: res.showDiscount || false,
|
||||
_id: res._id || "",
|
||||
});
|
||||
setFetched(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAd();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{fetched && <MultiStepForm />}
|
||||
<div></div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
134
src/app/settings/my-billboards/page.tsx
Normal file
134
src/app/settings/my-billboards/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import FilterModal from "@/components/settings/my-billboards/FilterModal";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
import { IAdvertising } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
|
||||
function MyBillboard() {
|
||||
const router = useRouter();
|
||||
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [showFilterModal, setShowFilterModal] = useState<boolean>(false);
|
||||
const [statusFiler, setStatusFiler] = useState<string>("");
|
||||
const { data, isFetchingNextPage, refetch } = useInfiniteScroll({
|
||||
endpoint: "/advertising/user-advertising",
|
||||
queryKey: ["advertising", search],
|
||||
params: { search: search, status: statusFiler },
|
||||
});
|
||||
|
||||
const getAdStatusAndColor = (adStatus: string) => {
|
||||
const statusMap: Record<string, { label: string; color: string }> = {
|
||||
pre_payment: { label: "پرداخت نشده", color: "#aaa" },
|
||||
paid: { label: "در انتظار تایید", color: "#007bff" },
|
||||
accepted: { label: "منتشر شده", color: "#28a745" },
|
||||
rejected: { label: "رد شده", color: "#dc3545" },
|
||||
expired: { label: "منقضی شده", color: "#6c757d" },
|
||||
};
|
||||
|
||||
return statusMap[adStatus] || { label: "نامشخص", color: "#000" };
|
||||
};
|
||||
const clearFilters = () => {
|
||||
setStatusFiler("");
|
||||
};
|
||||
return (
|
||||
<Container>
|
||||
<PageTitle>بیلبورد</PageTitle>
|
||||
<div className="px-4 text-xs md:text-sm">
|
||||
<div className="flex items-center justify-between relative px-4 text-sm gap-3">
|
||||
<div className="w-full relative">
|
||||
<RoundedInput
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
placeholder="جستجو"
|
||||
className="w-full"
|
||||
/>
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
alt="filter icon"
|
||||
src="/images/icons/search-normal.svg"
|
||||
className=" min-w-[25px] absolute left-3 top-[7px]"
|
||||
onClick={() => setSearch(searchText)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => setShowFilterModal(true)}>
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
alt="filter icon"
|
||||
src="/images/icons/candle.svg"
|
||||
className="dark:invert min-w-[25px]"
|
||||
/>
|
||||
</button>
|
||||
<Link href={"/billboards/new"}>
|
||||
<Image
|
||||
width={28}
|
||||
height={28}
|
||||
alt="add icon"
|
||||
src="/images/icons/add.svg"
|
||||
className="dark:invert min-w-[25px]"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full mt-10">
|
||||
{data?.pages.length === 0 ||
|
||||
(data?.pages[0]?.advertisings?.length === 0 &&
|
||||
!isFetchingNextPage) ? (
|
||||
<p className="text-center text-gray-500">بیلبوردی ثبت نشده است.</p>
|
||||
) : (
|
||||
data?.pages?.map((page, pageIndex) => (
|
||||
<React.Fragment key={pageIndex}>
|
||||
{page?.advertisings?.map((item: IAdvertising) => {
|
||||
const { label } = getAdStatusAndColor(item?.status || "");
|
||||
return (
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/settings/my-billboards/${item?._id}/${item?.title}`
|
||||
)
|
||||
}
|
||||
key={item?._id}
|
||||
>
|
||||
<MainBillboardCard
|
||||
adStatus={label}
|
||||
color={"#fff"}
|
||||
borderColor={"#ccc"}
|
||||
textColor={"#111"}
|
||||
billboard={item}
|
||||
showExpire={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showFilterModal && (
|
||||
<FilterModal
|
||||
setShowFilterModal={setShowFilterModal}
|
||||
showFilterModal={showFilterModal}
|
||||
clearFilters={clearFilters}
|
||||
statusFiler={statusFiler}
|
||||
setStatusFiler={setStatusFiler}
|
||||
refetch={refetch}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyBillboard;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user