2 Commits

Author SHA1 Message Date
Reza Taghizadeh
eb83cbd05b fix: reslove some bugs (v3.125.520) 2026-03-03 19:43:31 +03:30
Reza Taghizadeh
c9c16672e7 fix: reslove some bugs (v3.125.520) 2026-03-03 18:11:08 +03:30
10 changed files with 1555 additions and 244 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "app.salona",
"private": true,
"version": "3.124.520",
"version": "3.125.520",
"type": "module",
"scripts": {
"dev": "vite --host",

View File

@@ -0,0 +1,323 @@
import { useState } from "react";
import toast from "react-hot-toast";
import Select from "react-select";
import makeAnimated from "react-select/animated";
// فرض می‌کنیم JobFormData اینجا تعریف شده باشد اگر در فایل دیگری نیست
interface JobFormData {
job_title: string;
seniority_level: string[];
type_of_co_operation: string[];
at_least_work_experience: number | null;
internship: boolean;
province: string;
city: string;
address: string;
advantage_list: string[]; // این فیلد در این فرم نیست، اما اگر در جای دیگری استفاده می‌شود، خوب است که باشد
content: string; // این فیلد در این فرم نیست
owner_name: string; // این فیلد در این فرم نیست
}
interface SelectOption {
value: string;
label: string;
}
// نمونه داده‌ها
const seniorityOptions: SelectOption[] = [
{ value: "entry_level", label: "بدون سابقه (کارآموز)" },
{ value: "junior", label: "سطح پایین (تا 2 سال)" },
{ value: "mid_level", label: "نیمه ماهر (2 تا 5 سال)" },
{ value: "senior", label: "ارشد (5+ سال)" },
{ value: "lead", label: "سرپرست" },
];
const cooperationOptions: SelectOption[] = [
{ value: "full_time", label: "تمام وقت" },
{ value: "part_time", label: "پاره وقت" },
{ value: "contract", label: "پروژه‌ای/قراردادی" },
{ value: "remote", label: "دورکاری" },
{ value: "percentage", label: "درصدی" },
];
const animatedComponents = makeAnimated();
// استایل‌های سفارشی برای react-select
const selectStyles = {
control: (base: any, { isFocused }: any) => ({
...base,
padding: "0.7rem 0.8rem", // کمی padding بیشتر
borderRadius: "8px",
border: isFocused ? "1px solid #60a5fa" : "1px solid #d1d5eb", // آبی ملایم در فوکوس
boxShadow: isFocused ? "0 0 0 2px rgba(96, 165, 250, 0.3)" : "none", // سایه ظریف در فوکوس
"&:hover": { borderColor: "#93c5fd" }, // آبی روشن‌تر در هاور
minHeight: "50px", // ارتفاع بیشتر
fontSize: "0.95rem", // فونت کمی بزرگتر
}),
multiValue: (base: any) => ({
...base,
backgroundColor: "#3b82f6", // رنگ اصلی آبی
borderRadius: "4px",
color: "white",
padding: "2px 6px",
}),
multiValueLabel: (base: any) => ({
...base,
color: "white",
fontSize: "0.9rem",
}),
multiValueRemove: (base: any) => ({
...base,
backgroundColor: "#f87171", // رنگ قرمز ملایم برای حذف
color: "white",
borderRadius: "4px",
padding: "4px",
marginLeft: "6px",
"&:hover": { backgroundColor: "#ef4444" }, // قرمز تیره‌تر در هاور
}),
option: (base: any, { isFocused, isSelected }: any) => ({
...base,
backgroundColor: isSelected ? "#3b82f6" : (isFocused ? "#e0e7ff" : "white"), // آبی برای انتخاب شده، آبی خیلی روشن برای فوکوس
color: isSelected ? "white" : "black", // متن سفید در حالت انتخاب شده
padding: "0.8rem",
fontSize: "0.9rem",
cursor: "pointer",
}),
placeholder: (base: any) => ({ ...base, color: "#9ca3af", fontSize: "0.95rem" }), // خاکستری تیره‌تر برای placeholder
input: (base: any) => ({ ...base, margin: "0", padding: "0" }),
valueContainer: (base: any) => ({ ...base, padding: "0 0.5rem" }),
indicatorSeparator: (base: any) => ({ ...base, display: 'none' }), // حذف جداکننده
};
interface StepOneFormProps {
formData: JobFormData;
setFormData: React.Dispatch<React.SetStateAction<JobFormData>>; // اصلاح type
onNextStep: () => void;
}
const StepOneForm: React.FC<StepOneFormProps> = ({ formData, setFormData, onNextStep }) => {
const [selectedSeniority, setSelectedSeniority] = useState<SelectOption[]>(
(formData?.seniority_level?.map((val) => seniorityOptions.find((opt) => opt.value === val)) as SelectOption[])?.filter(Boolean) || [],
);
const [selectedCooperation, setSelectedCooperation] = useState<SelectOption[]>(
(formData?.type_of_co_operation?.map((val) => cooperationOptions.find((opt) => opt.value === val)) as SelectOption[])?.filter(Boolean) || [],
);
// مدیریت تغییرات ورودی‌های عادی
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
//@ts-ignore
const { name, value, type, checked } = e.target;
setFormData((prev) => ({ ...prev, [name]: type === "checkbox" ? checked : value }));
};
// مدیریت تغییرات سلکت‌ها
const handleSelectChange = (selectedOptions: any, actionMeta: any) => {
const name = actionMeta.name;
if (actionMeta.action === "clear") {
if (name === "seniority") {
setSelectedSeniority([]);
setFormData((prev) => ({ ...prev, seniority_level: [] }));
} else if (name === "cooperation") {
setSelectedCooperation([]);
setFormData((prev) => ({ ...prev, type_of_co_operation: [] }));
}
return;
}
const newSelected = selectedOptions as SelectOption[];
const values = newSelected.map((opt) => opt.value);
if (name === "seniority") {
setSelectedSeniority(newSelected);
setFormData((prev) => ({ ...prev, seniority_level: values }));
} else if (name === "cooperation") {
setSelectedCooperation(newSelected);
setFormData((prev) => ({ ...prev, type_of_co_operation: values }));
}
};
// اعتبارسنجی فرم
const validateStepOne = (): boolean => {
let isValid = true;
if (!formData?.job_title?.trim()) {
toast.error("عنوان شغلی الزامی است.");
isValid = false;
}
if (!formData?.seniority_level || formData?.seniority_level.length === 0) {
toast.error("حداقل یک رده سازمانی انتخاب کنید.");
isValid = false;
}
if (!formData?.type_of_co_operation || formData?.type_of_co_operation.length === 0) {
toast.error("حداقل یک نوع همکاری انتخاب کنید.");
isValid = false;
}
const experience = formData?.at_least_work_experience;
console.log(experience);
if (experience !== null && typeof experience === "number" && experience < 0) {
toast.error("سابقه کار باید یک عدد نامنفی باشد.");
isValid = false;
}
// می‌تونید اعتبارسنجی‌های بیشتری اضافه کنید (مثلا برای شهر و استان)
return isValid;
};
// رفتن به مرحله بعد
const handleNext = () => {
if (validateStepOne()) {
onNextStep();
}
};
return (
<div className="bg-white p-8 rounded-xl shadow-lg max-w-4xl mx-auto"> {/* پس‌زمینه سفید، padding، گرد، سایه و حداکثر عرض */}
<h2 className="text-3xl font-bold text-gray-800 mb-6 text-center">اطلاعات شغلی</h2> {/* عنوان بزرگ و مرکز */}
{/* عنوان شغلی */}
<div className="mb-6">
<label htmlFor="job_title" className="block text-lg font-semibold text-gray-700 mb-2"> {/* عنوان بولد و با فاصله */}
عنوان شغلی <span className="text-red-500">*</span>
</label>
<input
type="text"
id="job_title"
name="job_title"
value={formData?.job_title || ""}
onChange={handleInputChange}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-400 focus:border-transparent shadow-sm" // استایل جدید ورودی
placeholder="مثلا: برنامه‌نویس فرانت‌اند ارشد"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6"> {/* چیدمان دو ستونه */}
{/* رده سازمانی */}
<div>
<label htmlFor="seniority_level" className="block text-lg font-semibold text-gray-700 mb-2">
رده سازمانی <span className="text-red-500">*</span>
</label>
<Select
id="seniority_level"
name="seniority" // نام برای actionMeta
// closeOnSelect={false}
// components={{ ...animatedComponents, MultiValueRemove: ({ children, ...props }) => <components.MultiValueRemove {...props}><div className="custom-multi-value-remove">{children}</div></components.MultiValueRemove> }} // کامپوننت سفارشی برای حذف
isMulti
options={seniorityOptions}
styles={selectStyles}
placeholder="انتخاب رده سازمانی..."
value={selectedSeniority}
onChange={handleSelectChange}
/>
</div>
{/* نوع همکاری */}
<div>
<label htmlFor="type_of_co_operation" className="block text-lg font-semibold text-gray-700 mb-2">
نوع همکاری <span className="text-red-500">*</span>
</label>
<Select
id="type_of_co_operation"
name="cooperation" // نام برای actionMeta
// closeOnSelect={false}
components={animatedComponents}
isMulti
options={cooperationOptions}
styles={selectStyles}
placeholder="انتخاب نوع همکاری..."
value={selectedCooperation}
onChange={handleSelectChange}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* حداقل سابقه کار */}
<div>
<label htmlFor="at_least_work_experience" className="block text-lg font-semibold text-gray-700 mb-2">
حداقل سابقه کار (سال)
</label>
<input
type="number"
id="at_least_work_experience"
name="at_least_work_experience"
value={formData?.at_least_work_experience ?? ""} // استفاده از ?? برای نمایش خالی به جای 0
onChange={handleInputChange}
min="0" // تضمین عدد نامنفی
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-400 focus:border-transparent shadow-sm"
placeholder="مثلا: 3"
/>
</div>
{/* استان و شهر (در صورت نیاز به فیلدهای جداگانه) */}
{/* این قسمت رو میشه بهتر کرد، مثلا با استفاده از کامپوننت‌های مخصوص شهر و استان */}
<div>
<label htmlFor="province" className="block text-lg font-semibold text-gray-700 mb-2">
استان
</label>
<input
type="text"
id="province"
name="province"
value={formData?.province || ""}
onChange={handleInputChange}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-400 focus:border-transparent shadow-sm"
placeholder="مثلا: تهران"
/>
</div>
<div>
<label htmlFor="city" className="block text-lg font-semibold text-gray-700 mb-2">
شهر
</label>
<input
type="text"
id="city"
name="city"
value={formData?.city || ""}
onChange={handleInputChange}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-400 focus:border-transparent shadow-sm"
placeholder="مثلا: تهران"
/>
</div>
</div>
{/* آدرس */}
<div className="mb-6">
<label htmlFor="address" className="block text-lg font-semibold text-gray-700 mb-2">
آدرس دقیق
</label>
<textarea
id="address"
name="address"
value={formData?.address || ""}
onChange={handleInputChange}
rows={3} // تعداد خطوط پیش‌فرض
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-400 focus:border-transparent shadow-sm resize-y" // قابلیت تغییر اندازه عمودی
placeholder="خیابان، کوچه، پلاک..."
/>
</div>
{/* چک باکس کارآموز */}
<div className="mb-6 flex items-center">
<input
type="checkbox"
id="internship"
name="internship"
checked={formData?.internship || false}
onChange={handleInputChange}
className="h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500 ml-3" // استایل چک‌باکس
/>
<label htmlFor="internship" className="text-lg text-gray-700">
امکان جذب کارآموز
</label>
</div>
{/* دکمه مرحله بعد */}
<div className="flex justify-end mt-8"> {/* چیدمان راست */}
<button
onClick={handleNext}
className="bg-[#AA00FF] hover:bg-[#E6B4FF] text-white font-bold py-3 px-8 rounded-lg shadow-md hover:shadow-lg transition duration-300 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" // استایل دکمه
>
مرحله بعد
</button>
</div>
</div>
);
};
export default StepOneForm;

View File

@@ -0,0 +1,340 @@
import { useState, useEffect, useMemo } from "react";
import Select from "react-select";
import makeAnimated from "react-select/animated";
import toast from "react-hot-toast";
import { Textarea } from "@/components/ui/textarea";
// --- Interface ها و داده‌های نمونه ---
interface JobFormData {
job_title: string;
seniority_level: string[];
type_of_co_operation: string[];
at_least_work_experience: number | null;
internship: boolean;
province: string;
city: string;
address: string;
advantage_list: string[];
content: string;
owner_name: string; // این فیلد در این فرم استفاده نمی‌شود
}
interface SelectOption {
value: string;
label: string;
}
// نمونه داده‌ها برای انتخاب‌ها
const provinceOptions: SelectOption[] = [
{ value: "tehran", label: "تهران" },
{ value: "khorasan_razavi", label: "خراسان رضوی" },
{ value: "isfahan", label: "اصفهان" },
// ... استان‌های دیگر
];
// map استان‌ها به شهرها
const cityOptionsMap: { [provinceValue: string]: SelectOption[] } = {
tehran: [
{ value: "tehran_city", label: "تهران" },
{ value: "shahr_rey", label: "شهر ری" },
{ value: "varamin", label: "ورامین" },
],
khorasan_razavi: [
{ value: "mashhad", label: "مشهد" },
{ value: "torghabeh", label: "طرقبه" },
{ value: "chenaran", label: "چناران" },
],
isfahan: [
{ value: "isfahan_city", label: "اصفهان" },
{ value: "khomeinishahr", label: "خمینی‌شهر" },
{ value: "najafabad", label: "نجف‌آباد" },
],
};
const advantageOptions: SelectOption[] = [
{ value: "tea_coffee", label: "چای و کافی" },
{ value: "lunch", label: "ناهار" },
{ value: "transportation", label: "سرویس رفت و آمد" },
{ value: "insurance", label: "بیمه تکمیلی" },
{ value: "bonus", label: "پاداش" },
{ value: "gym", label: "باشگاه ورزشی" },
];
// استایل‌های سفارشی برای react-select (هماهنگ با مرحله قبل)
const animatedComponents = makeAnimated();
const selectStyles = {
control: (base: any, state: any) => ({
...base,
padding: "0.7rem 0.8rem", // کمی padding بیشتر
borderRadius: "8px",
border: state.isDisabled ? "1px solid #e5e7eb" : (state.isFocused ? "1px solid #60a5fa" : "1px solid #d1d5eb"), // آبی ملایم در فوکوس
boxShadow: state.isFocused ? "0 0 0 2px rgba(96, 165, 250, 0.3)" : "none", // سایه ظریف در فوکوس
"&:hover": { borderColor: state.isDisabled ? "#e5e7eb" : "#93c5fd" }, // آبی روشن‌تر در هاور
minHeight: "50px", // ارتفاع بیشتر
fontSize: "0.95rem", // فونت کمی بزرگتر
backgroundColor: state.isDisabled ? "#f9fafb" : base.backgroundColor, // پس‌زمینه خاکستری روشن برای غیرفعال
}),
multiValue: (base: any) => ({
...base,
backgroundColor: "#3b82f6", // رنگ اصلی آبی
borderRadius: "4px",
color: "white",
padding: "2px 6px",
}),
multiValueLabel: (base: any) => ({
...base,
color: "white",
fontSize: "0.9rem",
}),
multiValueRemove: (base: any) => ({
...base,
backgroundColor: "#f87171", // رنگ قرمز ملایم برای حذف
color: "white",
borderRadius: "4px",
padding: "4px",
marginLeft: "6px",
"&:hover": { backgroundColor: "#ef4444" }, // قرمز تیره‌تر در هاور
}),
option: (base: any, { isFocused, isSelected }: any) => ({
...base,
backgroundColor: isSelected ? "#3b82f6" : (isFocused ? "#e0e7ff" : "white"), // آبی برای انتخاب شده، آبی خیلی روشن برای فوکوس
color: isSelected ? "white" : "black", // متن سفید در حالت انتخاب شده
padding: "0.8rem",
fontSize: "0.9rem",
cursor: "pointer",
}),
placeholder: (base: any) => ({ ...base, color: "#9ca3af", fontSize: "0.95rem" }), // خاکستری تیره‌تر برای placeholder
input: (base: any) => ({ ...base, margin: "0", padding: "0" }),
valueContainer: (base: any) => ({ ...base, padding: "0 0.5rem" }),
indicatorSeparator: (base: any) => ({ ...base, display: 'none' }), // حذف جداکننده
};
interface StepTwoFormProps {
formData: JobFormData;
setFormData: React.Dispatch<React.SetStateAction<JobFormData>>; // اصلاح type
onSubmit: () => void; // تابع نهایی ارسال
}
const StepTwoForm: React.FC<StepTwoFormProps> = ({ formData, setFormData, onSubmit }) => {
// State محلی برای مقادیر انتخاب شده
const initialProvince = formData.province
? provinceOptions.find((p) => p.value === formData.province) || null
: null;
const [selectedProvince, setSelectedProvince] = useState<SelectOption | null>(initialProvince);
const availableCities = useMemo(() => {
return selectedProvince ? cityOptionsMap[selectedProvince.value] || [] : [];
}, [selectedProvince]);
const initialCity = formData.city && selectedProvince
? (cityOptionsMap[selectedProvince.value]?.find((c) => c.value === formData.city)) || null
: null;
const [selectedCity, setSelectedCity] = useState<SelectOption | null>(initialCity);
const initialAdvantages = formData.advantage_list
?.map((val) => advantageOptions.find((opt) => opt.value === val))
.filter(Boolean) as SelectOption[] || [];
const [selectedAdvantages, setSelectedAdvantages] = useState<SelectOption[]>(initialAdvantages);
// بروزرسانی شهرها و ریست کردن شهر انتخاب شده در صورت تغییر استان
useEffect(() => {
if (selectedProvince && selectedCity && !availableCities.some((city) => city.value === selectedCity.value)) {
setSelectedCity(null);
setFormData((prev) => ({ ...prev, city: "" }));
}
else if (!selectedProvince) { // اگر استانی انتخاب نشده، شهر را هم خالی کن
setSelectedCity(null);
setFormData((prev) => ({ ...prev, city: "" }));
}
}, [selectedProvince, selectedCity, availableCities, setFormData]);
// مدیریت تغییرات ورودی‌های عادی (آدرس و شرح شغل)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
// مدیریت تغییرات در کامپوننت‌های react-select
const handleSelectChange = (selected: any, actionMeta: any) => {
const name = actionMeta.name;
if (actionMeta.action === "clear") {
if (name === "province") {
setSelectedProvince(null);
setFormData((prev) => ({ ...prev, province: "", city: "" }));
} else if (name === "city") {
setSelectedCity(null);
setFormData((prev) => ({ ...prev, city: "" }));
} else if (name === "advantages") {
setSelectedAdvantages([]);
setFormData((prev) => ({ ...prev, advantage_list: [] }));
}
return;
}
// پردازش انتخاب استان
if (name === "province") {
const selectedOption = selected as SelectOption | null;
setSelectedProvince(selectedOption);
setFormData((prev) => ({ ...prev, province: selectedOption?.value || "" }));
// اگر استان عوض شد، شهر را ریست می‌کنیم (این کار در useEffect هم انجام می‌شود اما اینجا هم هندل می‌کنیم)
if (selectedOption?.value !== formData.province) {
setSelectedCity(null);
setFormData((prev) => ({ ...prev, city: "" }));
}
}
// پردازش انتخاب شهر
else if (name === "city") {
const selectedOption = selected as SelectOption | null;
setSelectedCity(selectedOption);
setFormData((prev) => ({ ...prev, city: selectedOption?.value || "" }));
}
// پردازش انتخاب مزایا
else if (name === "advantages") {
const newSelected = selected as SelectOption[];
setSelectedAdvantages(newSelected);
setFormData((prev) => ({ ...prev, advantage_list: newSelected.map((opt) => opt.value) }));
}
};
// اعتبارسنجی مرحله دوم
const validateStepTwo = (): boolean => {
if (!formData.province) {
toast.error("لطفاً استان را انتخاب کنید.");
return false;
}
if (!formData.city) {
toast.error("لطفاً شهر را انتخاب کنید.");
return false;
}
if (!formData.address?.trim()) {
toast.error("آدرس دقیق الزامی است.");
return false;
}
if (!formData.content?.trim()) {
toast.error("شرح شغل و توضیحات الزامی است.");
return false;
}
return true;
};
// اجرای تابع نهایی ارسال
const handleSubmit = () => {
if (validateStepTwo()) {
onSubmit(); // صدا زدن تابع ارسال نهایی از کامپوننت والد
}
};
// تعیین وضعیت فعال/غیرفعال بودن شهر
const isCitySelectDisabled = !selectedProvince || availableCities.length === 0;
return (
<div className="bg-white p-8 rounded-xl shadow-lg max-w-4xl mx-auto"> {/* پس‌زمینه سفید، padding، گرد، سایه و حداکثر عرض */}
<h2 className="text-3xl font-bold text-gray-800 mb-6 text-center">جزئیات آگهی</h2> {/* عنوان بزرگ و مرکز */}
{/* بخش استان، شهر و آدرس */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* استان */}
<div>
<label htmlFor="province" className="block text-lg font-semibold text-gray-700 mb-2">
استان <span className="text-red-500">*</span>
</label>
<Select
id="province"
name="province"
options={provinceOptions}
styles={selectStyles}
placeholder="انتخاب استان..."
value={selectedProvince}
onChange={handleSelectChange}
isClearable // امکان پاک کردن انتخاب
/>
</div>
{/* شهر */}
<div>
<label htmlFor="city" className="block text-lg font-semibold text-gray-700 mb-2">
شهر <span className="text-red-500">*</span>
</label>
<Select
id="city"
name="city"
options={availableCities}
styles={selectStyles}
placeholder="انتخاب شهر..."
value={selectedCity}
onChange={handleSelectChange}
isDisabled={isCitySelectDisabled} // غیرفعال کردن اگر استان انتخاب نشده
isClearable
/>
{/* نمایش پیام راهنما در صورت غیرفعال بودن */}
{isCitySelectDisabled && selectedProvince && (
<p className="text-sm text-gray-500 mt-1">برای انتخاب شهر، ابتدا استان را مشخص کنید.</p>
)}
</div>
</div>
{/* آدرس دقیق */}
<div className="mb-6">
<label htmlFor="address" className="block text-lg font-semibold text-gray-700 mb-2">
آدرس دقیق <span className="text-red-500">*</span>
</label>
<Textarea
id="address"
name="address"
value={formData?.address || ""}
onChange={handleInputChange}
rows={3}
className="resize-y" // اجازه تغییر اندازه عمودی
placeholder="خیابان، کوچه، پلاک، واحد..."
/>
</div>
{/* مزایای شغلی */}
<div className="mb-6">
<label htmlFor="advantage_list" className="block text-lg font-semibold text-gray-700 mb-2">
مزایای شغلی
</label>
<Select
id="advantage_list"
name="advantages"
// closeOnSelect={false}
components={animatedComponents}
isMulti
options={advantageOptions}
styles={selectStyles}
placeholder="انتخاب مزایا (اختیاری)..."
value={selectedAdvantages}
onChange={handleSelectChange}
/>
</div>
{/* شرح شغل و توضیحات */}
<div className="mb-6">
<label htmlFor="content" className="block text-lg font-semibold text-gray-700 mb-2">
شرح شغل و توضیحات <span className="text-red-500">*</span>
</label>
<Textarea
id="content"
name="content"
value={formData?.content || ""}
onChange={handleInputChange}
rows={6} // ارتفاع بیشتر برای شرح شغل
className="resize-y"
placeholder="توضیحات کامل در مورد وظایف، مسئولیت‌ها، مهارت‌های مورد نیاز و..."
/>
</div>
{/* دکمه ثبت نهایی */}
<div className="flex justify-end mt-10"> {/* فاصله بیشتر با دکمه */}
<button
onClick={handleSubmit}
className="bg-[#AA00FF] hover:bg-[#E6B4FF] text-white font-bold py-3 px-10 rounded-lg shadow-md hover:shadow-lg transition duration-300 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" // استایل دکمه
>
ثبت آگهی
</button>
</div>
</div>
);
};
export default StepTwoForm;

View File

@@ -0,0 +1,232 @@
import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import toast from "react-hot-toast";
import StepOneForm from "./StepOneForm";
import StepTwoForm from "./StepTwoForm";
// تعریف ساختار کلی داده‌های فرم
export interface JobFormData {
job_title: string;
seniority_level: string[];
type_of_co_operation: string[];
at_least_work_experience: number | null;
internship: boolean;
province: string;
city: string;
address: string;
advantage_list: string[];
content: string;
owner_name: string;
}
const LOCAL_STORAGE_KEY = "CreateJobPositionFormData";
const CLEAR_KEYS_ON_UNMOUNT = ["some_other_key_if_needed"]; // اگر کلیدهای دیگری هم نیاز بود پاک بشه
const getDefaultFormData = (): JobFormData => ({
job_title: "",
seniority_level: [],
type_of_co_operation: [],
at_least_work_experience: null,
internship: false,
province: "",
city: "",
address: "",
advantage_list: [],
content: "",
owner_name: "سالن زیبایی غزل", // مقدار پیش‌فرض
});
const CreateJobPosition: React.FC = () => {
// const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// const headerFromUrl = searchParams.get("header"); // اگه پارامتری از URL اومد
// const createJobMutation = useCreateJobOpportunity();
// --- مدیریت وضعیت فرم و localStorage ---
const loadFormData = useCallback((): JobFormData => {
const savedData = localStorage.getItem(LOCAL_STORAGE_KEY);
if (savedData) {
try {
const parsedData: JobFormData = JSON.parse(savedData);
// اطمینان از وجود owner_name و بقیه فیلدها
return {
...getDefaultFormData(),
...parsedData,
owner_name: parsedData.owner_name || getDefaultFormData().owner_name,
};
} catch (error) {
console.error("Failed to parse saved form data:", error);
return getDefaultFormData();
}
}
return getDefaultFormData();
}, []);
const [formData, setFormData] = useState(loadFormData());
// ذخیره کردن فرم در localStorage هر بار که formData تغییر می‌کنه
useEffect(() => {
try {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(formData));
} catch (error) {
console.error("Failed to save form data to localStorage:", error);
}
}, [formData]);
// --- مدیریت مراحل (steps) ---
const [currentStep, setCurrentStep] = useState(() => {
const stepFromUrl = parseInt(searchParams.get("step") || "1", 10);
return stepFromUrl >= 1 && stepFromUrl <= 2 ? stepFromUrl : 1;
});
// بروزرسانی URL هنگام تغییر مرحله
useEffect(() => {
setSearchParams({ step: String(currentStep) }, { replace: true });
}, [currentStep, setSearchParams]);
const goToNextStep = useCallback(() => {
// اینجا می‌تونیم اعتبارسنجی مرحله فعلی رو انجام بدیم
// فعلاً فرض می‌کنیم اعتبارسنجی در خود کامپوننت مرحله انجام میشه
setCurrentStep((prev) => Math.min(prev + 1, 2)); // حداکثر تا مرحله ۲
}, []);
// const goToPreviousStep = useCallback(() => {
// setCurrentStep((prev) => Math.max(prev - 1, 1)); // حداقل تا مرحله ۱
// }, []);
// --- پاک کردن localStorage هنگام خروج از صفحه ---
useEffect(() => {
const handleUnmount = () => {
const path = window.location.pathname || "";
const keepRoutes = ["/appointments/advance", "/purchase-summary"]; // مسیرهایی که نباید پاک شوند
const shouldClear = !keepRoutes.some((r) => path.includes(r)); // اگر مسیر فعلی جزو مسیرهای نگهدارنده نبود، پاک کن
if (shouldClear) {
console.log("Clearing localStorage for CreateJobPosition...");
localStorage.removeItem(LOCAL_STORAGE_KEY);
CLEAR_KEYS_ON_UNMOUNT.forEach((key) => {
localStorage.removeItem(key);
console.log(`Cleared key: ${key}`);
});
}
};
// این تابع cleanup در زمان unmount شدن کامپوننت اجرا میشه
return handleUnmount;
}, []);
// --- اعتبارسنجی نهایی و ارسال ---
const validateFormData = useCallback((data: JobFormData): boolean => {
let isValid = true;
if (!data.job_title?.trim()) {
toast.error("عنوان شغلی الزامی است.");
isValid = false;
}
if (!data.seniority_level || data.seniority_level.length === 0) {
toast.error("حداقل یک رده سازمانی انتخاب کنید.");
isValid = false;
}
if (!data.type_of_co_operation || data.type_of_co_operation.length === 0) {
toast.error("حداقل یک نوع همکاری انتخاب کنید.");
isValid = false;
}
const experience = data.at_least_work_experience;
if (experience === null || typeof experience !== "number" || experience < 0) {
toast.error("سابقه کار باید یک عدد مثبت یا صفر باشد.");
isValid = false;
}
if (!data.province) {
// استان در مرحله ۲ چک میشه، اما اینجا هم برای اطمینان
toast.error("استان الزامی است.");
isValid = false;
}
if (!data.city) {
// شهر هم در مرحله ۲ چک میشه
toast.error("شهر الزامی است.");
isValid = false;
}
if (!data.address?.trim()) {
toast.error("آدرس دقیق الزامی است.");
isValid = false;
}
if (!data.advantage_list || data.advantage_list.length === 0) {
toast.error("حداقل یک مزیت شغلی انتخاب کنید.");
isValid = false;
}
if (!data.content?.trim()) {
toast.error("شرح شغل الزامی است.");
isValid = false;
}
if (!data.owner_name?.trim()) {
// اگر owner_name هم لازم بود
toast.error("نام مالک یا مسئول آگهی الزامی است.");
isValid = false;
}
return isValid;
}, []);
const handleFinalSubmit = async () => {
if (!validateFormData(formData)) {
// اگر اعتبارسنجی ناموفق بود، شاید بهتر باشه کاربر رو به مرحله مربوطه هدایت کنیم
// ولی فعلاً فقط خطا رو نشون میدیم
toast.error("لطفاً تمام فیلدهای ضروری را پر کنید.");
return;
}
try {
// const response = await createJobMutation.mutateAsync(formData); // استفاده واقعی از mutateAsync
console.log("Submitting job data:", formData); // برای تست
toast.success("آگهی شغلی با موفقیت ثبت شد!");
localStorage.removeItem(LOCAL_STORAGE_KEY); // پاک کردن داده‌ها بعد از موفقیت
// CLEAR_KEYS_ON_UNMOUNT.forEach((key) => localStorage.removeItem(key)); // اگر نیاز بود
setFormData(getDefaultFormData()); // ریست کردن فرم
setCurrentStep(1); // برگشت به مرحله اول
setSearchParams({}, { replace: true }); // پاک کردن پارامترهای URL
// navigate("/jobs/success"); // مسیریابی به صفحه موفقیت (اگر وجود دارد)
} catch (error) {
console.error("Failed to create job opportunity:", error);
toast.error("خطا در ثبت آگهی شغلی. لطفاً دوباره تلاش کنید.");
}
};
// --- تعیین عنوان مرحله فعلی ---
// const getStepTitle = () => {
// switch (currentStep) {
// case 1:
// return headerFromUrl || "اطلاعات شغلی";
// case 2:
// return headerFromUrl || "جزئیات موقعیت مکانی و شغل";
// default:
// return headerFromUrl || "ایجاد آگهی شغلی";
// }
// };
// --- رندر کامپوننت ---
return (
<div className="create-job-position-container">
{/* <h2 style={{ marginBottom: "20px", color: "#333" }}>{getStepTitle()}</h2> */}
{/* نمایش مرحله فعلی */}
{currentStep === 1 && (
<StepOneForm
formData={formData}
setFormData={setFormData}
onNextStep={goToNextStep} // وقتی مرحله ۱ تموم شد، برو به مرحله ۲
/>
)}
{currentStep === 2 && (
<StepTwoForm
formData={formData}
setFormData={setFormData}
onSubmit={handleFinalSubmit} // تابع نهایی ارسال
/>
)}
</div>
);
};
export default CreateJobPosition;

View File

@@ -0,0 +1,51 @@
.shimmer {
position: relative;
display: inline-block;
font-weight: 700;
background: linear-gradient(90deg, #b266ff, #4b0082);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: colorShift 4s ease-in-out infinite alternate;
overflow: hidden;
}
.shimmer::before {
content: "";
position: absolute;
top: -50%;
left: -25%;
width: 50%;
height: 200%;
transform: rotate(-20deg);
background: linear-gradient(
120deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.8) 50%,
rgba(255, 255, 255, 0) 100%
);
animation: shimmerMove 2s ease-in-out infinite;
pointer-events: none;
}
@keyframes shimmerMove {
0% {
left: -40%;
}
50% {
left: 120%;
}
100% {
left: 120%;
}
}
@keyframes colorShift {
0% {
background-position: 0% 50%;
}
100% {
background-position: 100% 50%;
}
}

View File

@@ -0,0 +1,96 @@
// import { Link } from "react-router-dom";
import { Swiper, SwiperSlide } from "swiper/react";
import { useNavigate } from "react-router-dom";
import UtilitiesIcon from "@new-ui/assets/Utilities.svg";
import "./index.css";
const JobsModule = () => {
const navigate = useNavigate();
// Define job categories for the slider
const jobCategories = [
"ناخن کار",
"مژه کار",
"میکاپ کار",
"آرایشگر",
"دندانپزشک",
"طراح گرافیک",
"برنامه نویس",
"مدرس زبان",
"وکیل",
"مشاور املاک",
];
return (
<div className="w-full flex flex-col py-4 max-w-4xl mt-8 mx-auto">
{" "}
{/* افزایش padding عمودی و عرض max */}
<div className="w-full flex flex-col px-4 sm:px-0">
{" "}
{/* افزودن padding افقی */}
{/* Header */}
<div className="flex justify-between items-center mb-6 border-b pb-3 border-gray-200">
<div className="flex gap-2 items-center justify-start">
<img src={UtilitiesIcon} alt="Utilities icon" className="w-7 h-7" />
{/* <img src={jobSearchIcon} alt="Job Search Icon" className="w-7 h-7" /> */}
<p className="text-[#76558F] text-xl cursor-pointer">
کاریابی
</p>
</div>
<div className="flex gap-1 items-center justify-end">
<button
className="bg-[#76558F] text-white px-4 py-2 rounded-lg shadow-lg hover:bg-[#2D0A48] transition duration-300 transform hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-[#76558F] focus:ring-opacity-50"
onClick={() => navigate("/create-job-position")}
>
<p className="text-sm font-semibold">ثبت آگهی</p>
</button>
</div>
</div>
{/* Job Categories Slider - اصلاح شده برای فاصله و ظاهر بهتر */}
<div className="w-full mt-2">
<Swiper
spaceBetween={16} /* افزایش فاصله بین اسلایدها */
slidesPerView={"auto"}
initialSlide={0}
loop={false} /* اگر نیاز به تکرار ندارید */
grabCursor={true} /* برای حس بهتر کشیدن با ماوس */
style={{
width: "100%",
}}
>
{jobCategories.map((category, index) => (
<SwiperSlide
key={index}
className="flex-shrink-0" /* اطمینان از اینکه اسلایدها فشرده نشوند */
style={{ width: "fit-content" }} /* عرض خودکار بر اساس محتوا */
>
<div
className="
text-center
font-medium
text-base
px-6 py-3
text-[#2D0A48]
bg-white
border
border-[#76558F]
rounded-xl
shadow-md
hover:bg-[#f0e9f6]
transition duration-300
cursor-pointer
"
>
{category}
</div>
</SwiperSlide>
))}
</Swiper>
</div>
</div>
</div>
);
};
export default JobsModule;

View File

@@ -15,300 +15,319 @@ import { ChevronLeft } from "lucide-react";
import { useGetUserConfig } from "@/apps/new-ui/services/User";
import "./explore.css";
const BannerSwiper = lazy(() => import("../BannerSwiper/BannerSwiper"));
const AppointmentExplore = lazy(
() => import("../appointments/+components/AppointmentExplore"),
);
const TakeAppointment = lazy(
() => import("../appointments/+components/TakeAppointment"),
);
const AppointmentExplore = lazy(() => import("../appointments/+components/AppointmentExplore"));
const TakeAppointment = lazy(() => import("../appointments/+components/TakeAppointment"));
const Advertisement = lazy(() => import("../Advertisement"));
const Utilities = lazy(() => import("../../components/Utilities"));
const GiftCards = lazy(() => import("../GiftCardCreator"));
const JobsModule = lazy(() => import("../Jobs"));
const HowAreYouFeeling = lazy(() => import("../HowAreYouFeeling"));
const useInView = (offset = "200px") => {
const ref = useRef<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!ref.current) return;
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ rootMargin: offset },
);
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ rootMargin: offset },
);
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return { ref, visible };
return { ref, visible };
};
const KNOWN_CONFIG_KEYS = [
"default_appointment_pattern",
"default_appointment_sms",
"default_cashback_active",
"default_cashback_expire_days",
"default_cashback_percentage",
"default_reminder_pattern",
"default_reminder_sms",
"default_repair_pattern",
"default_repair_sms",
"default_survay_pattern",
"default_survay_sms",
"language",
"default_appointment_pattern",
"default_appointment_sms",
"default_cashback_active",
"default_cashback_expire_days",
"default_cashback_percentage",
"default_reminder_pattern",
"default_reminder_sms",
"default_repair_pattern",
"default_repair_sms",
"default_survay_pattern",
"default_survay_sms",
"language",
];
const UserCanAccess = [
"09117843141",
"09134801284",
"09934137207",
"09333694838",
"09930399200",
"09128560144",
"09128559089",
"09356975406",
"09920568930",
];
const looksLikeConfig = (obj: any) => {
if (!obj || typeof obj !== "object") return false;
return KNOWN_CONFIG_KEYS.some((k) =>
Object.prototype.hasOwnProperty.call(obj, k),
);
if (!obj || typeof obj !== "object") return false;
return KNOWN_CONFIG_KEYS.some((k) => Object.prototype.hasOwnProperty.call(obj, k));
};
const NewExplore = () => {
useFirebaseNotifier();
const [searchKey, setSearchKey] = useState("");
const navigate = useNavigate();
localStorage.removeItem("selectedCustomerId");
const { ref, visible } = useInView();
useFirebaseNotifier();
const [searchKey, setSearchKey] = useState("");
const navigate = useNavigate();
localStorage.removeItem("selectedCustomerId");
const { ref, visible } = useInView();
// const { data: userConfig } = useGetUserConfig();
const { data: userConfig, refetch: fetchUserConfig } = useGetUserConfig({
enabled: false,
staleTime: 1000 * 60 * 10, // 10 min
});
// const { data: userConfig } = useGetUserConfig();
const { data: userConfig, refetch: fetchUserConfig } = useGetUserConfig({
enabled: false,
staleTime: 1000 * 60 * 10, // 10 min
});
useEffect(() => {
const run = () => fetchUserConfig();
useEffect(() => {
const run = () => fetchUserConfig();
if ("requestIdleCallback" in window) {
requestIdleCallback(run);
} else {
setTimeout(run, 2000);
}
}, [fetchUserConfig]);
useEffect(() => {
if (userConfig && typeof window !== "undefined") {
try {
if (looksLikeConfig(userConfig)) {
const raw = localStorage.getItem("userConfig");
const existing = raw ? JSON.parse(raw) : {};
const merged = { ...existing, ...userConfig };
localStorage.setItem("userConfig", JSON.stringify(merged));
window.dispatchEvent(new Event("userConfigLoaded"));
if ("requestIdleCallback" in window) {
requestIdleCallback(run);
} else {
console.warn(
"Fetched userConfig does not contain expected keys, skipping overwrite.",
);
setTimeout(run, 2000);
}
} catch (e) {
console.error("Failed to store userConfig in localStorage:", e);
}
}
}, [userConfig]);
}, [fetchUserConfig]);
// ref + ارتفاع placeholder
const parallaxRef = useRef<HTMLDivElement | null>(null);
const [parallaxHeight, setParallaxHeight] = useState(0);
// برای کنترل افکت: progress و snapped
const [scrollProgress, setScrollProgress] = useState(0); // 0..1
const [snapped, setSnapped] = useState(false);
// به‌روزرسانی ارتفاع
useEffect(() => {
const update = () => {
if (parallaxRef.current) {
setParallaxHeight(parallaxRef.current.offsetHeight);
}
};
update();
// اگر تصاویر lazy-load دارن یا DOM بعدا تغییر میکنه، میشه MutationObserver اضافه کرد
const resizeObserver = new ResizeObserver(update);
if (parallaxRef.current) resizeObserver.observe(parallaxRef.current);
window.addEventListener("resize", update);
return () => {
window.removeEventListener("resize", update);
resizeObserver.disconnect();
};
}, []);
// اسکرول هندلر با RAF برای روانی
useEffect(() => {
if (!parallaxHeight) return;
let rafId: number | null = null;
const onScroll = () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const scrolled = window.scrollY || window.pageYOffset || 0;
const progress = Math.min(Math.max(scrolled / parallaxHeight, 0), 1);
setScrollProgress(progress);
// snap وقتی نصف یا بیشتر پوشیده شد
if (progress >= 0.5) {
if (!snapped) setSnapped(true);
} else {
if (snapped) setSnapped(false);
useEffect(() => {
if (userConfig && typeof window !== "undefined") {
try {
if (looksLikeConfig(userConfig)) {
const raw = localStorage.getItem("userConfig");
const existing = raw ? JSON.parse(raw) : {};
const merged = { ...existing, ...userConfig };
localStorage.setItem("userConfig", JSON.stringify(merged));
window.dispatchEvent(new Event("userConfigLoaded"));
} else {
console.warn("Fetched userConfig does not contain expected keys, skipping overwrite.");
}
} catch (e) {
console.error("Failed to store userConfig in localStorage:", e);
}
}
});
};
}, [userConfig]);
window.addEventListener("scroll", onScroll, { passive: true });
// مقدار اولیه
onScroll();
// ref + ارتفاع placeholder
const parallaxRef = useRef<HTMLDivElement | null>(null);
const [parallaxHeight, setParallaxHeight] = useState(0);
return () => {
window.removeEventListener("scroll", onScroll);
if (rafId) cancelAnimationFrame(rafId);
};
}, [parallaxHeight, snapped]);
// برای کنترل افکت: progress و snapped
const [scrollProgress, setScrollProgress] = useState(0); // 0..1
const [snapped, setSnapped] = useState(false);
// opacity داینامیک (خطی از 1 تا 0 تا progress = 0.5)
const dynamicOpacity = snapped ? 0 : 1 - Math.min(scrollProgress / 0.5, 1);
// به‌روزرسانی ارتفاع
useEffect(() => {
const update = () => {
if (parallaxRef.current) {
setParallaxHeight(parallaxRef.current.offsetHeight);
}
};
update();
useEffect(() => {
return () => {
if (window.location.pathname.includes("/explore")) {
localStorage.removeItem("servicePrice");
localStorage.removeItem("appointmentTime");
localStorage.removeItem("appointmentWeekday");
localStorage.removeItem("serviceCost");
}
};
}, []);
// اگر تصاویر lazy-load دارن یا DOM بعدا تغییر میکنه، میشه MutationObserver اضافه کرد
const resizeObserver = new ResizeObserver(update);
if (parallaxRef.current) resizeObserver.observe(parallaxRef.current);
return (
<div className="explore-page-wrapper flex flex-col pb-[105px] relative overflow-visible">
<div
ref={parallaxRef}
className={`parallax ${
snapped ? "parallax--snapped" : ""
} z-10 bg-gradient-to-br from-[#DFB4FF] to-[#E6C4FF] overflow-visible relative`}
style={{
opacity: dynamicOpacity,
}}
>
<AdsModal />
window.addEventListener("resize", update);
return () => {
window.removeEventListener("resize", update);
resizeObserver.disconnect();
};
}, []);
<div className="relative flex items-center justify-center mt-4">
<div className="relative w-[220px] h-[220px] overflow-hidden ">
<img
src={transparentWoman}
alt="transparent"
loading="lazy"
decoding="async"
className="absolute rounded-full bottom-0"
/>
// اسکرول هندلر با RAF برای روانی
useEffect(() => {
if (!parallaxHeight) return;
<img
src={womanWorking}
alt="woman working"
loading="lazy"
decoding="async"
className="absolute bottom-0 -right-6"
data-aos="fade-up"
/>
</div>
<div className="z-12 -mr-12">
<p
className="text-lg font-light text-black/85"
data-aos="fade-down"
let rafId: number | null = null;
const onScroll = () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const scrolled = window.scrollY || window.pageYOffset || 0;
const progress = Math.min(Math.max(scrolled / parallaxHeight, 0), 1);
setScrollProgress(progress);
// snap وقتی نصف یا بیشتر پوشیده شد
if (progress >= 0.5) {
if (!snapped) setSnapped(true);
} else {
if (snapped) setSnapped(false);
}
});
};
window.addEventListener("scroll", onScroll, { passive: true });
// مقدار اولیه
onScroll();
return () => {
window.removeEventListener("scroll", onScroll);
if (rafId) cancelAnimationFrame(rafId);
};
}, [parallaxHeight, snapped]);
// opacity داینامیک (خطی از 1 تا 0 تا progress = 0.5)
const dynamicOpacity = snapped ? 0 : 1 - Math.min(scrollProgress / 0.5, 1);
useEffect(() => {
return () => {
if (window.location.pathname.includes("/explore")) {
localStorage.removeItem("servicePrice");
localStorage.removeItem("appointmentTime");
localStorage.removeItem("appointmentWeekday");
localStorage.removeItem("serviceCost");
}
};
}, []);
const storedAccounts = localStorage.getItem("accounts") ?? "";
const parsedData = storedAccounts
? JSON.parse(storedAccounts)
: { pointer: 0, length: 0, 0: { phone_number: undefined } };
const phoneNumber = parsedData[parsedData.pointer]?.phone_number;
const [isUserCanAccess, setIsUserCanAccess] = useState(false);
const closeSupportTimeoutRef = useRef<number | null>(null);
// cleanup any pending timeout on unmount
useEffect(() => {
const access = UserCanAccess.includes(phoneNumber ?? "");
setIsUserCanAccess(access);
return () => {
if (closeSupportTimeoutRef.current) {
window.clearTimeout(closeSupportTimeoutRef.current);
}
};
}, [phoneNumber]);
return (
<div className="explore-page-wrapper flex flex-col pb-[105px] relative overflow-visible">
<div
ref={parallaxRef}
className={`parallax ${
snapped ? "parallax--snapped" : ""
} z-10 bg-gradient-to-br from-[#DFB4FF] to-[#E6C4FF] overflow-visible relative`}
style={{
opacity: dynamicOpacity,
}}
>
پشتیبانی سالونا
</p>
<p className="text-xs text-[#000000DE] mt-2" data-aos="fade-down">
پاسخگوی سوالات شماست
</p>
<div className="relative w-[120px] mt-4 z-50">
<button
className=" border-2 text-sm text-[#2D0A48] flex items-center justify-center gap-1 border-[#F7EEFF] bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-[#F3E2FF80] to-[#F3E2FF] w-[120px] h-9 rounded-full "
onClick={() => navigate("/faq")}
>
ارتباط
<ChevronLeft color="#2D0A48" size={18} />
</button>
<AdsModal />
<div className="relative flex items-center justify-center mt-4">
<div className="relative w-[220px] h-[220px] overflow-hidden ">
<img
src={transparentWoman}
alt="transparent"
loading="lazy"
decoding="async"
className="absolute rounded-full bottom-0"
/>
<img
src={womanWorking}
alt="woman working"
loading="lazy"
decoding="async"
className="absolute bottom-0 -right-6"
data-aos="fade-up"
/>
</div>
<div className="z-12 -mr-12">
<p className="text-lg font-light text-black/85" data-aos="fade-down">
پشتیبانی سالونا
</p>
<p className="text-xs text-[#000000DE] mt-2" data-aos="fade-down">
پاسخگوی سوالات شماست
</p>
<div className="relative w-[120px] mt-4 z-50">
<button
className=" border-2 text-sm text-[#2D0A48] flex items-center justify-center gap-1 border-[#F7EEFF] bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-[#F3E2FF80] to-[#F3E2FF] w-[120px] h-9 rounded-full "
onClick={() => navigate("/faq")}
>
ارتباط
<ChevronLeft color="#2D0A48" size={18} />
</button>
</div>
</div>
<div className="bg-gradient-to-t from-white to-white/0 w-full absolute h-12 -bottom-1 z-10" />
</div>
</div>
</div>
<div className="bg-gradient-to-t from-white to-white/0 w-full absolute h-12 -bottom-1 z-10" />
</div>
</div>
{/* placeholder که جای parallax را وقتی fixed می‌شود نگه می‌دارد */}
<div
className="parallax-placeholder"
style={{ height: parallaxHeight ? `${parallaxHeight}px` : undefined }}
/>
{/* placeholder که جای parallax را وقتی fixed می‌شود نگه می‌دارد */}
<div
className="parallax-placeholder"
style={{ height: parallaxHeight ? `${parallaxHeight}px` : undefined }}
/>
<div className="absolute flex items-center justify-between px-4 sm:px-28 overflow-visible top-0 left-0 right-0 py-3 gap-4 lg:gap-3">
<div className="flex gap-3">
<NotificationsPhoneStyle />
<img
src={icon3}
alt="icon3"
onClick={() => navigate(`${newAppointmentRoutes.prizes}`)}
className="cursor-pointer"
/>
</div>
<div className="flex gap-3">
<QRCodeDisplay url="https://aparat.com/example" />
<SearchInExplore value={searchKey} onChange={setSearchKey} />
</div>
</div>
<div className="absolute flex items-center justify-between px-4 sm:px-28 overflow-visible top-0 left-0 right-0 py-3 gap-4 lg:gap-3">
<div className="flex gap-3">
<NotificationsPhoneStyle />
<img
src={icon3}
alt="icon3"
onClick={() => navigate(`${newAppointmentRoutes.prizes}`)}
className="cursor-pointer"
/>
</div>
<div className="flex gap-3">
<QRCodeDisplay url="https://aparat.com/example" />
<SearchInExplore value={searchKey} onChange={setSearchKey} />
</div>
</div>
<div className="w-full bg-white z-10 max-w-2xl mx-auto overflow-visible">
{/* <div className="explore-page-div relative -mt-6 z-20 ">
<div className="w-full bg-white z-10 max-w-2xl mx-auto overflow-visible">
{/* <div className="explore-page-div relative -mt-6 z-20 ">
<div className="bg-gradient-to-t from-white to-white/0 w-full absolute h-12 -top-8 z-0" />
<BannerSwiper />
</div> */}
<Suspense
fallback={
<div className="h-32 animate-pulse bg-gray-100 rounded-xl" />
}
>
<BannerSwiper />
</Suspense>
<Suspense fallback={<div className="h-32 animate-pulse bg-gray-100 rounded-xl" />}>
<BannerSwiper />
</Suspense>
<Suspense fallback={null}>
<AppointmentExplore />
<TakeAppointment />
<Advertisement />
</Suspense>
<Suspense fallback={null}>
<AppointmentExplore />
<TakeAppointment />
<Advertisement />
</Suspense>
<div ref={ref}>
{visible && (
<Suspense fallback={null}>
<Utilities />
<GiftCards />
<HowAreYouFeeling />
</Suspense>
)}
</div>
<div ref={ref}>
{visible && (
<Suspense fallback={null}>
<Utilities />
<GiftCards />
{isUserCanAccess && <JobsModule />}
<HowAreYouFeeling />
</Suspense>
)}
</div>
{/* <AppointmentExplore />
{/* <AppointmentExplore />
<TakeAppointment />
<Advertisement />
<Utilities />
<GiftCards /> */}
{/* <Events />
{/* <Events />
<Store /> */}
{/* <HowAreYouFeeling /> */}
</div>
</div>
);
{/* <HowAreYouFeeling /> */}
</div>
</div>
);
};
export default NewExplore;

View File

@@ -67,6 +67,7 @@ export const newAppointmentRoutes = Object.freeze({
customersEdit: "/customers/edit/:id",
PersonnelReservedAppointments: "/personnel-reserved-appointments",
addReservedCustomer: "/reserved-appointments/add-customer",
CreateJobPosition: "/create-job-position",
confirmReservationAppointment: "/confirm-reservation-appointment",
appointment_detail: "/appointment-detail/:id",
roseAssistantReports: "/rose-assistant/reports",

View File

@@ -82,6 +82,7 @@ import Accounting from "../pages/ReportAll/Accounting";
import FactorList from "../pages/FactorList";
import ActiveAccounts from "../pages/ActiveAccounts";
import SMSSetting from "../pages/SmsSetting/SMSSetting";
import CreateJobPosition from "../pages/Jobs/create-job-position";
// import Dashboard from "../pages/new-ui-card/pages/dashboard";
export const appointmentRoutesStructure = [
@@ -374,6 +375,10 @@ export const appointmentRoutesStructure = [
path: newAppointmentRoutes.addReservedCustomer,
element: <AddReservedCustomer />,
},
{
path: newAppointmentRoutes.CreateJobPosition,
element: <CreateJobPosition />,
},
{
path: newAppointmentRoutes.prizes,
// element: <Prizes />,

View File

@@ -0,0 +1,244 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { appointmentClient } from "@/utils/axios-interceptor";
import toast from "react-hot-toast";
import { useSearchParams } from "react-router-dom";
const jobQueryKeys = {
JOB_OPPORTUNITIES: "jobOpportunities",
};
// 1. دریافت همه آگهی‌های عمومی
export const useGetAllPublicJobOpportunities = (page: number = 1) => {
return useQuery({
queryKey: [jobQueryKeys.JOB_OPPORTUNITIES, "public", page],
queryFn: async () => {
// تغییر: حذف هدرهای اضافی که در curl بودند ولی معمولاً در client لازم نیستند
// و استفاده از appointmentClient
try {
const { data } = await appointmentClient.get(`/get_job_opportunities/${page}`);
return data;
} catch (error: any) {
console.error("Error fetching public job opportunities:", error);
toast.error(error?.response?.data?.message || "خطا در دریافت آگهی‌های عمومی");
throw error;
}
},
enabled: true,
});
};
export const useGetAllMyJobOpportunities = (page: number = 1) => {
return useQuery({
queryKey: [jobQueryKeys.JOB_OPPORTUNITIES, "my", page],
queryFn: async () => {
// تغییر: حذف هدرهای اضافی و استفاده از appointmentClient
// اضافه کردن توکن به صورت داینامیک در صورت نیاز (باید تو client تنظیم بشه یا اینجا اضافه کنی)
try {
const { data } = await appointmentClient.get(`/get_my_job_opportunities/${page}`);
return data;
} catch (error: any) {
console.error("Error fetching my job opportunities:", error);
toast.error(error?.response?.data?.message || "خطا در دریافت آگهی‌های شما");
throw error;
}
},
});
};
// 3. دریافت یک آگهی به صورت عمومی
export const useGetSinglePublicJobOpportunity = (jobId: string | number | undefined) => {
return useQuery({
queryKey: [jobQueryKeys.JOB_OPPORTUNITIES, "public", jobId],
queryFn: async () => {
// تغییر: حذف هدرهای اضافی و استفاده از appointmentClient
try {
const { data } = await appointmentClient.get(`/get_single_job_opportunity/${jobId}`);
return data;
} catch (error: any) {
console.error(`Error fetching single public job opportunity ${jobId}:`, error);
toast.error(error?.response?.data?.message || `خطا در دریافت آگهی ${jobId}`);
throw error;
}
},
enabled: !!jobId,
});
};
// 4. دریافت یکی از آگهی‌های خودش (نیاز به توکن)
export const useGetMySingleJobOpportunity = (jobId: string | number | undefined) => {
return useQuery({
queryKey: [jobQueryKeys.JOB_OPPORTUNITIES, "my", jobId],
queryFn: async () => {
// تغییر: حذف هدرهای اضافی و استفاده از appointmentClient، اضافه کردن توکن
try {
const { data } = await appointmentClient.get(`/get_my_single_job_opportunity/${jobId}`);
return data;
} catch (error: any) {
console.error(`Error fetching my single job opportunity ${jobId}:`, error);
toast.error(error?.response?.data?.message || `خطا در دریافت آگهی شما ${jobId}`);
throw error;
}
},
enabled: !!jobId,
});
};
// 5. ویرایش یکی از آگهی‌های خودش
interface UpdateJobOpportunityBody {
address?: string;
salary_or_percentage?: string;
job_title?: string;
owner_name?: string;
seniority_level?: string[];
type_of_co_operation?: string;
at_least_work_experience?: number;
province?: string;
city?: string;
advantage_list?: string[];
content?: string;
internship?: boolean;
}
export const useUpdateJobOpportunity = () => {
return useMutation({
mutationFn: async ({ jobId, updateData }: { jobId: string | number; updateData: UpdateJobOpportunityBody }) => {
// تغییر: حذف هدرهای اضافی و استفاده از appointmentClient
try {
const { data } = await appointmentClient.put(`/update_job_opportunity/${jobId}`, updateData);
return data;
} catch (error: any) {
console.error(`Error updating job opportunity ${jobId}:`, error);
toast.error(error?.response?.data?.message || "خطا در به‌روزرسانی آگهی");
throw error;
}
},
onSuccess: () => {
toast.success("آگهی با موفقیت به‌روزرسانی شد.");
// queryClient.invalidateQueries([jobQueryKeys.JOB_OPPORTUNITIES]);
},
onError: (error: any) => {
toast.error(error?.message || "خطا در به‌روزرسانی آگهی");
},
});
};
// 6. حذف یکی از آگهی‌های خودش
export const useDeleteJobOpportunity = () => {
return useMutation({
mutationFn: async (jobId: string | number) => {
// تغییر: حذف هدرهای اضافی و استفاده از appointmentClient
try {
const { data } = await appointmentClient.delete(`/delete_job_opportunity/${jobId}`);
return data;
} catch (error: any) {
console.error(`Error deleting job opportunity ${jobId}:`, error);
toast.error(error?.response?.data?.message || "خطا در حذف آگهی");
throw error;
}
},
onSuccess: () => {
toast.success("آگهی با موفقیت حذف شد.");
// queryClient.invalidateQueries([jobQueryKeys.JOB_OPPORTUNITIES]);
},
onError: (error: any) => {
toast.error(error?.message || "خطا در حذف آگهی");
},
});
};
// 7. ساخت آگهی جدید
interface CreateJobOpportunityBody {
job_title: string;
owner_name: string;
seniority_level: string[];
type_of_co_operation: string;
salary_or_percentage: string;
at_least_work_experience: number;
province: string;
city: string;
advantage_list: string[];
content: string;
internship: boolean;
address: string;
}
export const useCreateJobOpportunity = () => {
return useMutation({
mutationFn: async (newJobData: CreateJobOpportunityBody) => {
// تغییر: حذف هدرهای اضافی و استفاده از appointmentClient
try {
const { data } = await appointmentClient.post("/create_job_opportunity", newJobData);
return data;
} catch (error: any) {
console.error("Error creating job opportunity:", error);
toast.error(error?.response?.data?.message || "خطا در ایجاد آگهی");
throw error;
}
},
onSuccess: () => {
toast.success("آگهی با موفقیت ایجاد شد.");
// queryClient.invalidateQueries([jobQueryKeys.JOB_OPPORTUNITIES]);
},
onError: (error: any) => {
toast.error(error?.message || "خطا در ایجاد آگهی");
},
});
};
// --- هوک‌های جدید ---
// 8. شمارش آگهی‌ها بر اساس کلمات کلیدی
export const useCountJobsByKeywords = () => {
return useQuery({
queryKey: [jobQueryKeys.JOB_OPPORTUNITIES, "countByKeywords"],
queryFn: async () => {
// تغییر: حذف هدرهای اضافی و استفاده از appointmentClient
try {
const { data } = await appointmentClient.get("/count_jobs_by_keywords");
return data;
} catch (error: any) {
console.error("Error counting jobs by keywords:", error);
toast.error(error?.response?.data?.message || "خطا در شمارش آگهی‌ها");
throw error;
}
},
enabled: true,
});
};
// 9. جستجوی آگهی‌ها
interface SearchJobOpportunitiesParams {
province?: string | null;
city?: string | null;
type_of_cooperation?: string | null;
title?: string;
}
export const useSearchJobOpportunities = (params: SearchJobOpportunitiesParams, page: number = 1) => {
const [searchParams, setSearchParams] = useSearchParams();
const urlPage = Number(searchParams.get("page") ?? page);
return useQuery({
queryKey: [jobQueryKeys.JOB_OPPORTUNITIES, "search", params, urlPage],
queryFn: async () => {
// تغییر: حذف هدرهای اضافی و استفاده از appointmentClient
const searchBody: Record<string, any> = {};
if (params.province) searchBody.province = params.province;
if (params.city) searchBody.city = params.city;
if (params.type_of_cooperation) searchBody.type_of_cooperation = params.type_of_cooperation;
if (params.title) searchBody.title = params.title;
try {
const { data } = await appointmentClient.post(`/search_job_opportunities/${urlPage}`, searchBody);
setSearchParams({ page: String(urlPage) }, { replace: true });
return data;
} catch (error: any) {
console.error("Error searching job opportunities:", error);
toast.error(error?.response?.data?.message || "خطا در جستجوی آگهی‌ها");
throw error;
}
},
enabled: !!params.title || !!params.province || !!params.city || !!params.type_of_cooperation,
// keepPreviousData: true,
});
};