Files
hamed 56e264e44c feat: Refactor specialty display logic and enhance specialty filtering
- Introduced SpecialtyChips component to manage the display of doctor's specialties with a primary specialty and a count of additional specialties.
- Updated ItemDoctor component to utilize SpecialtyChips for better UI presentation.
- Enhanced PosterLight and Poster components to display primary specialties and a text line for sub-specialties.
- Implemented nextSpecialtyFilter function to improve specialty selection logic in the Content component.
- Updated search functionality to allow searching by both doctor name and specialty.
- Added new specialties to specialties.json for better coverage.
- Created sync-specialties script to synchronize specialties data with the backend during build.
- Added tests for new components and helper functions to ensure functionality and reliability.
2026-08-08 17:19:22 +03:30

816 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { toast } from "react-toastify";
import Cookies from "js-cookie";
import moment from "moment-jalaali";
import "moment-timezone";
// Data
import specialties from "@/data/specialties.json";
import states from "@/data/state.json";
import cities from "@/data/city.json";
export const numberToArStyle = (num) => num?.toLocaleString("ar-AE") || "";
// عناوین احترامی که در نام ذخیره‌شدهٔ پزشک وجود ندارند و باید پیش از جستجو حذف شوند
const NAME_HONORIFICS = [
"دکتر",
"دكتر",
"پروفسور",
"خانم",
"آقای",
"اقای",
"آقا",
"اقا",
"جناب",
"سرکار",
];
// «دکتر مهدیه» → «مهدیه» تا جستجوی نام در API نتیجه بدهد.
// اگر بعد از حذف چیزی نماند، مقدار اصلی برگردانده می‌شود.
export const normalizeDoctorNameQuery = (value) => {
if (typeof value !== "string") return value;
let result = value.trim();
let changed = true;
while (changed) {
changed = false;
for (const honorific of NAME_HONORIFICS) {
if (result === honorific || result.startsWith(`${honorific} `)) {
result = result.slice(honorific.length).trim();
changed = true;
}
}
}
return result || value.trim();
};
export const isActiveURL = (link, name) => link === name;
// نگاشت پاسخ API بلاگ (image_url/tags/created_at/body/author-object) به شکلی که
// کامپوننت‌های بلاگ انتظار دارند (images[{url}]/tag[{name}]/created/body.value/author-string).
export const normalizeBlog = (blog) => {
if (!blog) return null;
return {
...blog,
images: blog.image_url ? [{ url: blog.image_url }] : [],
tag: Array.isArray(blog.tags)
? blog.tags.map((t) => (typeof t === "string" ? { name: t } : t))
: [],
created: blog.created_at ?? blog.created ?? null,
body:
blog.body && typeof blog.body === "object"
? blog.body
: { value: blog.body ?? "" },
author:
blog.author && typeof blog.author === "object"
? blog.author.name
: blog.author ?? null,
};
};
// تبدیل مسیر نسبی تصویر (/uploads/...) به URL کامل backend.
// مسیرهای absolute (http...) و asset های محلی (/assets، /default) دست‌نخورده می‌مانند.
// تصویر پیش‌فرض همهٔ مقاله‌ها — لیست، کارت، جزئیات، OG و توییتر همگی همین را
// می‌گیرند تا هیچ مقاله‌ای بدون تصویر (یا با آواتار کاربر) نمایش داده نشود.
export const BLOG_FALLBACK_IMG = "/assets/images/blog-default-cover.png";
/**
* کاور یک مقاله با ترتیب تقدم og_image → image_url → images[0].
* جدا از imageUrl() است چون پیش‌فرض آن آواتار کاربر است و برای بلاگ غلط.
*/
export const blogCover = (blog) =>
imageUrl(
blog?.og_image || blog?.image_url || blog?.images?.[0]?.url,
BLOG_FALLBACK_IMG
);
export const imageUrl = (url, fallback = "/assets/images/user.png") => {
if (!url) return fallback;
if (/^https?:\/\//.test(url)) return url;
if (url.startsWith("/uploads")) {
const base = process.env.NEXT_PUBLIC_API_URL || "";
return `${base}${url}`;
}
return url;
};
// نام پزشک با پیشوند «دکتر»، بدون تکرار. بعضی رکوردها خودشان با «دکتر»/«دکتر.»
// ذخیره شده‌اند؛ بدون این نرمال‌سازی خروجی «دکتر دکتر ...» می‌شود.
export const doctorTitle = (name) => {
const clean = (name || "").trim().replace(/^(دکتر|دكتر)[.،:]?\s+/, "");
return clean ? `دکتر ${clean}` : "";
};
export const convertToJalali = (date) => {
const currentDate = date;
const jalaliDate = moment(currentDate).format("jYYYY/jM/jD");
const jalaliYear = moment(jalaliDate, "jYYYY/jM/jD").jYear();
const jalaliMonth = moment(jalaliDate, "jYYYY/jM/jD").jMonth() + 1;
const jalaliDay = moment(jalaliDate, "jYYYY/jM/jD").jDate();
return `${jalaliYear}/${jalaliMonth}/${jalaliDay}`;
};
export const convertTimestampToJalali = (timestamp) => {
if (!timestamp) return "--";
const date = moment.unix(timestamp);
return date.format("jYYYY/jMM/jDD");
};
export const convertTimestampToTime = (timestamp) => {
if (!timestamp) return "--";
const date = moment.unix(timestamp);
return date.format("HH:mm");
};
export const handleTwoLength = (value) =>
String(value).length === 1 ? `0${value}` : value;
export const checkAllValues = (data) => {
for (let key in data) {
const item = data[key];
if (typeof item === "object" && item !== null) {
if ("value" in item) {
if (!item.value) {
return false;
}
} else {
if (!checkAllValues(item)) {
return false;
}
}
}
}
return true;
};
export const checkTimes = (arr) => {
return arr.map((day) => {
const hasMorningTime = day.morning_time.some((time) => time !== "");
const hasEveningTime = day.evening_time.some((time) => time !== "");
return hasMorningTime && hasEveningTime;
});
};
export const uploadImage = async (event) => {
let obj = {
file: "",
blob: "",
};
const file = event.target.files[0];
if (file) {
obj.file = file;
const readFileAsDataURL = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = (error) => reject(error);
reader.readAsDataURL(file);
});
};
try {
obj.blob = await readFileAsDataURL(file);
return obj;
} catch (error) {
console.error("Error reading file: ", error);
throw error;
}
} else {
throw new Error("No file selected");
}
};
export const alert = (type, text, prop) => {
toast[type](
<div className="h-full w-full flex flex-col items-start justify-center gap-[13px]">
<p className="text-[14px] font-medium">
پیغام{" "}
{type === "error"
? "خطا"
: type === "warning"
? "هشدار"
: type === "info"
? "اعلام"
: "موفقیت"}
</p>
<p className="text-[14px] font-normal">{text}</p>
</div>,
{ prop }
);
};
export const formatPhoneNumber = (input) => {
if (typeof input !== "string") input = String(input);
const normalized = input
.replace(/[\u06F0-\u06F9]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
.replace(/[\u0660-\u0669]/g, (d) => String(d.charCodeAt(0) - 0x0660));
const digits = normalized.replace(/\D/g, "");
const trimmed = digits.substring(0, 9);
const match = trimmed.match(/^(\d{0,2})(\d{0,3})(\d{0,4})$/);
if (!match) return trimmed;
return [match[1], match[2], match[3]].filter(Boolean).join(" ");
};
export function validatePhoneNumber(input) {
if (!input) {
return { valid: false, text: "شماره موبایل نمی‌تواند خالی باشد." };
}
// چک کردن اینکه فقط اعداد انگلیسی باشد
const hasNonEnglishDigits = /[^0-9]/.test(input);
if (hasNonEnglishDigits) {
return { valid: false, text: "فقط از اعداد انگلیسی استفاده کنید." };
}
if (input.length !== 11) {
return { valid: false, text: "شماره موبایل باید دقیقا 11 رقم باشد." };
}
const isValid = /^09\d{9}$/.test(input);
if (!isValid) {
return { valid: false, text: "شماره موبایل نامعتبر است." };
}
return { valid: true, text: null };
}
export const searchOnList = (list, value, name) => {
let newList = list;
newList = list.filter((item) => item[name]?.toLowerCase()?.includes(value));
return newList;
};
export function clearFilterParams(router, listRemove) {
const currentParams = new URLSearchParams(window.location.search);
const keysToRemove = listRemove;
keysToRemove.forEach((key) => currentParams.delete(key));
const queryString = currentParams.toString();
const newUrl = queryString ? `/doctors?${queryString}` : "/doctors";
router.push(newUrl);
}
export function clearDoctorClinicParams(router, slug) {
const clinicslug = slug;
const newUrl = `/clinic/${clinicslug}`;
router.push(newUrl)
}
/** گزینهٔ «کل گروه» در فهرست زیرتخصص‌ها؛ با id تهی، چون حالتش نبودِ زیرتخصص است. */
export const allOfCategoryOption = (category) => ({
id: null,
name: `همه تخصص‌های ${category.name}`,
isAll: true,
});
/**
* فیلتر بعدی وقتی کاربر گروه یا زیرتخصص را عوض می‌کند.
*
* تابع خالص است چون همین قاعده بود که شکسته بود: انتخاب یک گروه بی‌صدا اولین
* زیرتخصصش را می‌نشاند و جستجوی کاربر را تنگ می‌کرد. داخل کامپوننت، تست‌پذیر نبود.
*/
export const nextSpecialtyFilter = (filter, name, value) => {
const next = { ...filter, [name]: value };
// گروه یعنی کل گروه، نه اولین فرزندش.
if (name === "category") next.specialty = null;
// «همه تخصص‌های X» زیرتخصص واقعی نیست؛ حالتش همان نبودِ زیرتخصص است. اگر خودش
// در فیلتر می‌نشست، نامش در URL می‌رفت و چون در specialties.json نیست، با رفرش
// یا اشتراک لینک بازسازی نمی‌شد.
if (name === "specialty" && value?.isAll) next.specialty = null;
return next;
};
export const filterList = (data) => {
const parentList = specialties.filter((item) => !item.parent_id);
const childrenList = specialties.filter((item) => item.parent_id);
if (!data?.category) {
return { parentList, childrenList: [] };
}
const ofCategory = childrenList.filter(
(item) => String(item.parent_id) === String(data.category.id)
);
// «همه» اول فهرست می‌نشیند تا برگشت از یک زیرتخصص به کل گروه ممکن باشد.
// خودش وارد فیلتر نمی‌شود؛ انتخابش یعنی specialty تهی، و آن‌وقت QueryForDoctorsReq
// به category.id برمی‌گردد که بک‌اند به همهٔ زیرشاخه‌ها گسترشش می‌دهد.
return {
parentList,
childrenList: ofCategory.length
? [allOfCategoryOption(data.category), ...ofCategory]
: [],
};
};
export function getCleanNumberValue(value, defaultValue = 0) {
if (typeof value !== "string") value = String(value);
const persianArabicToEnglish = value
.replace(/[\u06F0-\u06F9]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
.replace(/[\u0660-\u0669]/g, (d) => String(d.charCodeAt(0) - 0x0660));
const cleaned = persianArabicToEnglish.replace(/[^0-9.]/g, "");
const number = parseFloat(cleaned);
return isNaN(number) ? defaultValue : number;
}
export function hasDataChanged(previousData, newData) {
const oldDataString = JSON.stringify(
previousData,
Object.keys(previousData).sort()
);
const newDataString = JSON.stringify(newData, Object.keys(newData).sort());
return oldDataString !== newDataString;
}
export const getParsedUserInfo = () => {
const user = Cookies.get("userInfo");
if (!user) return null;
try {
return JSON.parse(user);
} catch {
return null;
}
};
export const handleTimeExpiresToken = (expires_in) => {
const accessTokenExpires = new Date();
accessTokenExpires.setSeconds(accessTokenExpires.getSeconds() + expires_in);
const refreshTokenExpires = new Date();
refreshTokenExpires.setDate(refreshTokenExpires.getDate() + 30);
return {
accessTokenExpires,
refreshTokenExpires,
};
};
export const removeAdditionalKeysDashboard = (information) => {
// فقط متادیتای سمت فرانت حذف می‌شود؛ national_code باید ارسال شود تا قابل ثبت/آپدیت باشد.
const {
changed,
created,
id,
status,
prev_data,
uuid,
...usedKays
} = information;
return usedKays;
};
export const changeDateType = (data, toTimeStamp) => {
const newData = { ...data };
const birthday = newData.birthday;
if (toTimeStamp) {
const m = moment(birthday, "jYYYY/jMM/jDD");
newData.birthday = m.unix();
} else {
newData.birthday = moment.unix(birthday).format("jYYYY/jMM/jDD");
}
return newData;
};
export const removeTokenHead = {
headers: {
Authorization: "",
},
};
export const dateToTimestamp = (date) => {
// تنظیم timezone به تهران
const tehranMoment = date.clone().tz("Asia/Tehran").startOf("day");
// Get the Unix timestamp in seconds
const inSeconde = tehranMoment.unix();
return inSeconde;
};
export const degreeVal = [
{ name: "پزشک عمومی", val: "general" },
{ name: "پزشک متخصص", val: "specialist" },
{ name: "پزشک فوق تخصص", val: "subspecialistplus" },
];
export const addLabelToList = (list) =>
list.map((item) => {
return {
...item,
label: item.name,
};
});
export const QueryForDoctorsFilter = (newFilter) => {
const query = {};
// 🔹 active => only when value === 1
if (newFilter.active === 1) {
query.active = 1;
}
// 🔹 specialty or category
if (newFilter.specialty) {
query.specialty = newFilter.specialty.name;
} else if (newFilter.category) {
query.specialty = newFilter.category.name;
}
// 🔹 city
if (newFilter.city) {
query.city = newFilter.city.name;
}
// 🔹 state
if (newFilter.state) {
query.state = newFilter.state.name;
}
// 🔹 degree (only if not "همه موارد")
if (newFilter.degree && newFilter.degree !== "همه موارد") {
query.degree = newFilter.degree;
}
// 🔹 gender (only if not "هر دو")
if (newFilter.gender && newFilter.gender !== "هر دو") {
query.gender = newFilter.gender;
}
// 🔹 name (only if has value)
if (newFilter.name && newFilter.name.trim() !== "") {
query.name = newFilter.name;
}
// 🔹 sort => only if value is 3,4,5
if ([3, 4, 5].includes(Number(newFilter.sort))) {
// query.sort = newFilter.sort === 3 ? "ASC" : "DESC";
query.sort = newFilter.sort;
}
return query;
};
export const QueryForClinicDoctorFilter = (newFilter) => {
const query = {};
// 🔹 active => only when value === 1
if (newFilter.active === 1) {
query.active = 1;
}
// 🔹 specialty or category
if (newFilter.specialty) {
query.specialty = newFilter.specialty.name;
} else if (newFilter.category) {
query.specialty = newFilter.category.name;
}
// 🔹 degree (only if not "همه موارد")
if (newFilter.degree && newFilter.degree !== "همه موارد") {
query.degree = newFilter.degree;
}
// 🔹 gender (only if not "هر دو")
if (newFilter.gender && newFilter.gender !== "هر دو") {
query.gender = newFilter.gender;
}
// 🔹 name (only if has value)
if (newFilter.name && newFilter.name.trim() !== "") {
query.name = newFilter.name;
}
// 🔹 sort => only if value is 3,4,5
if ([3, 4, 5].includes(Number(newFilter.sort))) {
// query.sort = newFilter.sort === 3 ? "ASC" : "DESC";
query.sort = newFilter.sort;
}
return query;
}
export const QueryForDoctorsReq = (newFilter) => {
const params = {};
if (newFilter.active === 1) params.active = 1;
if (newFilter.specialty?.id) {
params.specialty_id = newFilter.specialty.id;
} else if (newFilter.category?.id) {
params.specialty_id = newFilter.category.id;
}
if (newFilter.city?.id) params.city_id = newFilter.city.id;
if (newFilter.state?.id) params.state_id = newFilter.state.id;
if (newFilter.degree && newFilter.degree !== "همه موارد")
switch (newFilter.degree) {
case "کارشناس":
params.degree = "expert";
break;
case "پزشک عمومی":
params.degree = "general";
break;
case "پزشک متخصص":
params.degree = "specialist";
break;
case "پزشک فوق تخصص":
params.degree = "subspecialistplus";
break;
default:
break;
}
if (newFilter.gender && newFilter.gender !== "هر دو")
params.gender = newFilter.gender === "مرد" ? "man" : "woman";
if (newFilter.name) params.name = normalizeDoctorNameQuery(newFilter.name);
if (newFilter.sort) {
if (newFilter.sort === 3) params.sort = "ASC";
else if (newFilter.sort === 4 || newFilter.sort === 5) params.sort = "DESC";
}
return params;
};
export const QueryForDoctorsClinicReq = (newFilter) => {
const params = {};
if (newFilter.active === 1) params.active = 1;
if (newFilter.specialty?.id) {
params.specialty = newFilter.specialty.id;
} else if (newFilter.category?.id) {
params.specialty = newFilter.category.id;
}
if (newFilter.degree && newFilter.degree !== "همه موارد")
switch (newFilter.degree) {
case "کارشناس":
params.degree = "expert";
break;
case "پزشک عمومی":
params.degree = "general";
break;
case "پزشک متخصص":
params.degree = "specialist";
break;
case "پزشک فوق تخصص":
params.degree = "subspecialistplus";
break;
default:
break;
}
if (newFilter.gender && newFilter.gender !== "هر دو")
params.gender = newFilter.gender === "مرد" ? "man" : "woman";
if (newFilter.name) params.name = normalizeDoctorNameQuery(newFilter.name);
if (newFilter.sort) {
if (newFilter.sort === 3) params.sort = "ASC";
else if (newFilter.sort === 4 || newFilter.sort === 5) params.sort = "DESC";
}
return params;
}
export const buildDoctorParams = (searchParams) => {
const params = {};
// 🔹 active
if (searchParams.active && Number(searchParams.active) === 1) {
params.active = 1;
}
// 🔹 specialty (find by name in specialties.json)
if (searchParams.specialty) {
const spec = specialties.find((s) => s.name === searchParams.specialty);
if (spec) {
params.specialty_id = spec.id;
}
}
// 🔹 city (find by name in city.json)
if (searchParams.city) {
const cityObj = cities.find((c) => c.name === searchParams.city);
if (cityObj) {
params.city_id = cityObj.id;
}
}
// 🔹 state (find by name in state.json)
if (searchParams.state) {
const stateObj = states.find((s) => s.name === searchParams.state);
if (stateObj) {
params.state_id = stateObj.id;
}
}
// 🔹 degree → convert fa → en
if (searchParams.degree) {
switch (searchParams.degree) {
case "کارشناس":
params.degree = "expert";
break;
case "پزشک عمومی":
params.degree = "general";
break;
case "پزشک متخصص":
params.degree = "specialist";
break;
case "پزشک فوق تخصص":
params.degree = "subspecialistplus";
break;
default:
break;
}
}
// 🔹 gender
if (searchParams.gender) {
if (searchParams.gender === "مرد") params.gender = "man";
if (searchParams.gender === "زن") params.gender = "woman";
}
// 🔹 name
if (searchParams.name) {
params.name = normalizeDoctorNameQuery(searchParams.name);
}
// 🔹 sort (3 = ASC, 4|5 = DESC)
if (searchParams.sort) {
const sortNum = Number(searchParams.sort);
if (sortNum === 3) params.sort = "ASC";
else if ([4, 5].includes(sortNum)) params.sort = "DESC";
}
// 🔹 page
params.page = searchParams.page || 1;
// 🔹 limit
params.limit = 12;
return params;
};
export const QueryForClinicsFilter = (searchParams) => {
const params = {};
// 🔹 specialty (find by name in specialties.json)
if (searchParams.specialty) {
const spec = specialties.find((s) => s.name === searchParams.specialty);
if (spec) {
params.specialty = spec.id;
}
}
// 🔹 city (find by name in city.json)
if (searchParams.city) {
const cityObj = cities.find((c) => c.name === searchParams.city.name);
if (cityObj) {
params.city = cityObj.id;
}
}
// 🔹 state (find by name in state.json)
if (searchParams.province) {
const stateObj = states.find((s) => s.name === searchParams.province.name);
if (stateObj) {
params.state = stateObj.id;
}
}
// 🔹 page
params.page = searchParams.page || 1;
// 🔹 limit
params.limit = 12;
return params;
};
export const buildClinicParams = (searchParams) => {
const params = {};
// 🔹 city (find by name in city.json)
if (searchParams.city) {
const cityObj = cities.find((c) => c.name === searchParams.city);
if (cityObj) {
params.city = cityObj.id;
}
}
// 🔹 state (find by name in state.json)
if (searchParams.state) {
const stateObj = states.find((s) => s.name === searchParams.state);
if (stateObj) {
params.state = stateObj.id;
}
}
// 🔹 specialty (find by name in specialties.json)
if (searchParams.specialty) {
const spec = specialties.find((s) => s.name === searchParams.specialty);
if (spec) {
params.specialty = spec.id;
}
}
// 🔹 page
params.page = searchParams.page || 1;
// 🔹 limit
params.limit = 12;
return params;
};
export function timeAgo(timestamp) {
const now = moment();
const then = moment.unix(timestamp);
const diffSeconds = now.diff(then, "seconds");
if (diffSeconds < 60) {
return diffSeconds === 0 ? "همین الان" : `${diffSeconds} ثانیه پیش`;
} else if (diffSeconds < 3600) {
const minutes = Math.floor(diffSeconds / 60);
return `${minutes} دقیقه پیش`;
} else if (diffSeconds < 86400) {
const hours = Math.floor(diffSeconds / 3600);
return `${hours} ساعت پیش`;
} else if (diffSeconds < 2592000) {
const days = Math.floor(diffSeconds / 86400);
return `${days} روز پیش`;
} else if (diffSeconds < 31104000) {
const months = Math.floor(diffSeconds / 2592000);
return `${months} ماه پیش`;
} else {
const years = Math.floor(diffSeconds / 31104000);
return `${years} سال پیش`;
}
}
export function isUserLoggedIn() {
// access_token در memory است نه کوکی؛ userInfo کوکیِ non-httpOnly است که هنگام لاگین ست می‌شود.
return !!Cookies.get("userInfo");
}
export function convertTimestampToPersianDateSimple(timestamp) {
const date = new Date(timestamp * 1000);
const options = {
timeZone: "Asia/Tehran",
calendar: "persian",
year: "numeric",
month: "2-digit",
day: "2-digit",
weekday: "long",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true,
};
return date.toLocaleString("fa-IR", options);
}
export function changeNumToDefault(str) {
if (!str) return "";
return str
.toString()
.replace(/[۰-۹]/g, d => "۰۱۲۳۴۵۶۷۸۹".indexOf(d))
.replace(/[٠-٩]/g, d => "٠١٢٣٤٥٦٧٨٩".indexOf(d));
}
// اعتبارسنجی کد ملی ایران: ۱۰ رقم + الگوریتم رقم کنترلی.
export const isValidIranNationalCode = (input) => {
if (!input) return false;
const code = String(input).replace(/\D/g, "");
if (!/^\d{10}$/.test(code)) return false;
// کدهای با ارقام یکسان (مثل 0000000000) نامعتبرند
if (/^(\d)\1{9}$/.test(code)) return false;
const check = +code[9];
let sum = 0;
for (let i = 0; i < 9; i++) sum += +code[i] * (10 - i);
const r = sum % 11;
return r < 2 ? check === r : check === 11 - r;
};