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.
This commit is contained in:
hamed
2026-08-08 17:19:22 +03:30
parent 94c22de8dd
commit 56e264e44c
16 changed files with 799 additions and 52 deletions
+2 -7
View File
@@ -5,6 +5,7 @@ import CustomLoading from "./loading/Custom";
import TextLoading from "./loading/Text";
import CircularLoading from "./loading/Circular";
import DoctorAvatar from "./DoctorAvatar";
import SpecialtyChips from "./SpecialtyChips";
// Icons
import ArrowLeftD from "@/components/icons/ArrowLeftD";
@@ -44,13 +45,7 @@ function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
</Link>
</TextLoading>
<TextLoading loading={loading} width={120} height={15}>
<p className="text-[#616161] text-[14px] font-normal">
تخصص:
{doctor?.specialties?.map(
(item, idx) =>
`${item.name} ${doctor.specialties.length === idx + 1 ? "" : "|"} `
)}
</p>
<SpecialtyChips specialties={doctor?.specialties} />
</TextLoading>
<CustomLoading loading={loading} width={100} height={25}>
<div className="flex rounded items-center justify-start gap-2">
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import { Popover } from "@mui/material";
import { splitSpecialties } from "@/lib/specialtyDisplay";
/**
* تخصص اصلی + شمارندهٔ بقیه در کارت پزشک.
*
* پیش از این همهٔ نام‌ها پشت‌سرهم چاپ می‌شدند و کارتِ پزشکِ شش‌تخصصی در موبایل
* چند برابر بقیه بلند می‌شد و شبکه را به‌هم می‌ریخت.
*
* جدا از ItemDoctor است تا مرز client فقط همین‌جا باشد؛ کارت از کامپوننت‌های
* سروری هم رندر می‌شود.
*/
function SpecialtyChips({ specialties }) {
const [anchorEl, setAnchorEl] = useState(null);
const { primary, rest } = splitSpecialties(specialties);
if (!primary) return null;
const open = Boolean(anchorEl);
// کارت داخل <Link> است؛ بدون این دو، هر کلیک روی شمارنده صفحهٔ پزشک را باز می‌کند.
const handleOpen = (event) => {
event.preventDefault();
event.stopPropagation();
setAnchorEl(event.currentTarget);
};
const handleClose = (event) => {
event?.preventDefault?.();
event?.stopPropagation?.();
setAnchorEl(null);
};
return (
<span className="flex items-center gap-1.5 min-w-0">
<span className="text-[#616161] text-[14px] font-normal truncate">
تخصص: {primary.name}
</span>
{rest.length > 0 && (
<>
<button
type="button"
onClick={handleOpen}
aria-label={`نمایش ${rest.length} تخصص دیگر`}
className="shrink-0 p-1 bg-[#F8F8FF] rounded-[4px] text-[#616161] text-[12px] font-medium leading-none"
>
+{rest.length}
</button>
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
<ul
dir="rtl"
className="p-3 flex flex-col gap-1.5 max-w-[260px] bg-[#FFF]"
>
{[primary, ...rest].map((item) => (
<li
key={item.id ?? item.name}
className="text-[#616161] text-[13px] font-normal"
>
{item.name}
</li>
))}
</ul>
</Popover>
</>
)}
</span>
);
}
export default SpecialtyChips;
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import Link from "next/link";
import SpecialtyChips from "@/app/component/SpecialtyChips";
const root = { id: "13", name: "جراحی عمومی", parent_id: null };
const gi = { id: "169", name: "جراح گوارش", parent_id: "13" };
const thyroid = { id: "168", name: "جراح تیروئید", parent_id: "13" };
describe("SpecialtyChips", () => {
it("تخصص اصلی را نشان می‌دهد و بقیه را می‌شمارد", () => {
render(<SpecialtyChips specialties={[gi, root, thyroid]} />);
expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "نمایش 2 تخصص دیگر" })).toHaveTextContent("+2");
// بقیه تا پیش از کلیک در DOM نیستند — همین کارت را کوتاه نگه می‌دارد.
expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
});
it("کلیک روی شمارنده فهرست کامل را باز می‌کند", () => {
render(<SpecialtyChips specialties={[root, gi, thyroid]} />);
fireEvent.click(screen.getByRole("button", { name: "نمایش 2 تخصص دیگر" }));
expect(screen.getByText("جراح گوارش")).toBeInTheDocument();
expect(screen.getByText("جراح تیروئید")).toBeInTheDocument();
// تخصص اصلی هم در فهرست هست تا تصویر کامل باشد.
expect(screen.getAllByText(/جراحی عمومی/).length).toBeGreaterThan(1);
});
it("کلیک روی شمارنده نباید لینک کارت را فعال کند", () => {
// کارت داخل <Link> است؛ بدون preventDefault/stopPropagation هر بار که کاربر
// تخصص‌ها را می‌بیند به صفحهٔ پزشک پرت می‌شود.
const onParentClick = vi.fn();
render(
<Link href="/doctor/uuid" onClick={onParentClick}>
<SpecialtyChips specialties={[root, gi]} />
</Link>
);
// fireEvent مقدار false می‌دهد وقتی preventDefault صدا زده شده باشد.
const notPrevented = fireEvent.click(
screen.getByRole("button", { name: "نمایش 1 تخصص دیگر" })
);
expect(notPrevented).toBe(false);
expect(onParentClick).not.toHaveBeenCalled();
});
it("تک‌تخصص هیچ شمارنده‌ای ندارد", () => {
render(<SpecialtyChips specialties={[root]} />);
expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("پزشک بدون تخصص چیزی رندر نمی‌کند و کرش نمی‌کند", () => {
const { container: empty } = render(<SpecialtyChips specialties={[]} />);
expect(empty).toBeEmptyDOMElement();
const { container: missing } = render(<SpecialtyChips specialties={undefined} />);
expect(missing).toBeEmptyDOMElement();
});
it("شمارنده متن دارد نه فقط علامت — برای صفحه‌خوان", () => {
render(<SpecialtyChips specialties={[root, gi, thyroid]} />);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("type", "button");
expect(button).toHaveAccessibleName("نمایش 2 تخصص دیگر");
});
});
+18 -10
View File
@@ -4,6 +4,7 @@ import DoctorAvatar from "@/app/component/DoctorAvatar";
import QrImg from "./QrImg";
import SiteLogo from "./SiteLogo";
import { getStateInfoClient } from "@/lib/getStateInfoClient";
import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay";
import { doctorTitle } from "@/helper";
import StarDI from "@/components/icons/StarDI";
@@ -21,7 +22,9 @@ function PosterLight({ data, onQrReady }) {
setCity(matchedCity || null);
}, []);
const specialties = (data?.specialties ?? []).filter((s) => s?.name).slice(0, 4);
// قرینهٔ پوستر تیره — همان دلیل: برش عددیِ ثابت با نام‌های بلند از کادر بیرون می‌زد.
const { primary, rest } = splitSpecialties(data?.specialties);
const subSpecialties = posterSpecialtyLine(rest, 78);
const services = (data?.expertise ?? []).filter((s) => s?.name);
const shownServices = services.slice(0, 8);
const moreServices = services.length - shownServices.length;
@@ -88,18 +91,23 @@ function PosterLight({ data, onQrReady }) {
{doctorTitle(data?.name)}
</h1>
<div className="w-full flex flex-wrap justify-center gap-[10px] mt-[16px]">
{specialties.map((s, i) => (
<span
key={i}
className="flex items-center shrink-0 rounded-full bg-[#E3EEF9] px-[20px] py-[9px]"
>
{primary && (
<div className="w-full flex justify-center mt-[16px]">
<span className="flex items-center shrink-0 rounded-full bg-[#E3EEF9] px-[20px] py-[9px]">
<span className="text-[#16528C] text-[23px] font-semibold leading-none whitespace-nowrap">
{s.name}
{primary.name}
</span>
</span>
))}
</div>
</div>
)}
{/* زیرتخصص‌ها یک خط متنی — در چاپ نه کلیک هست نه Popover. */}
{subSpecialties.text && (
<p className="w-full mt-[12px] max-h-[80px] overflow-hidden text-[#3E6E9E] text-[20px] font-medium leading-[40px]">
{subSpecialties.text}
{subSpecialties.hidden > 0 && ` و ${subSpecialties.hidden} تخصص دیگر`}
</p>
)}
{badges.length > 0 && (
<div className="w-full flex flex-wrap justify-center items-center gap-[14px] mt-[22px]">
+21 -10
View File
@@ -4,6 +4,7 @@ import DoctorAvatar from "@/app/component/DoctorAvatar";
import QrImg from "./QrImg";
import SiteLogoBase from "./SiteLogo";
import { getStateInfoClient } from "@/lib/getStateInfoClient";
import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay";
import { doctorTitle } from "@/helper";
import StarDI from "@/components/icons/StarDI";
@@ -41,7 +42,10 @@ function Poster({ data, onQrReady }) {
setCity(matchedCity || null);
}, []);
const specialties = (data?.specialties ?? []).filter((s) => s?.name).slice(0, 4);
// برش بر اساس طول متن است نه عدد ثابت: چهار چیپ با نام‌های بلند به سه ردیف
// می‌رفت، بخش hero را بلند می‌کرد و بخش‌های پایین را از کادر ثابت بیرون می‌انداخت.
const { primary, rest } = splitSpecialties(data?.specialties);
const subSpecialties = posterSpecialtyLine(rest, 78);
const services = (data?.expertise ?? []).filter((s) => s?.name);
const shownServices = services.slice(0, 8);
const moreServices = services.length - shownServices.length;
@@ -128,18 +132,25 @@ function Poster({ data, onQrReady }) {
<h1 className="text-white text-[46px] font-extrabold leading-tight">
{doctorTitle(data?.name)}
</h1>
<div className="flex flex-wrap gap-[10px] mt-[16px]">
{specialties.map((s, i) => (
<span
key={i}
className="flex items-center rounded-full bg-[#F59E0B]/18 border border-[#F59E0B]/40 px-[18px] py-[9px]"
>
{primary && (
<div className="mt-[16px] flex flex-wrap items-center gap-[12px]">
<span className="flex items-center rounded-full bg-[#F59E0B]/18 border border-[#F59E0B]/40 px-[18px] py-[9px]">
<span className="text-[#FCD9A0] text-[22px] font-semibold leading-none">
{s.name}
{primary.name}
</span>
</span>
))}
</div>
</div>
)}
{/* زیرتخصص‌ها یک خط متنی‌اند نه چیپ: در چاپ نه کلیک هست نه Popover، پس
«+N» بی‌معناست و متن خوانا جایش را می‌گیرد. max-h تور ایمنیِ کادر است
اگر بودجهٔ کاراکتر روزی کوتاه تنظیم شود. */}
{subSpecialties.text && (
<p className="mt-[12px] max-h-[80px] overflow-hidden text-[#C7DBF2] text-[20px] font-medium leading-[40px]">
{subSpecialties.text}
{subSpecialties.hidden > 0 && ` و ${subSpecialties.hidden} تخصص دیگر`}
</p>
)}
<div className="flex flex-wrap gap-[12px] mt-[18px]">
{badges.map((b, i) => (
<span
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import Poster from "@/components/doctor/poster";
import PosterLight from "@/components/doctor/poster/PosterLight";
vi.mock("@/lib/getStateInfoClient", () => ({
getStateInfoClient: () => ({ matchedCity: { site_name: "یاسوج نوبت", domain: "yasuj-nobat.ir" } }),
}));
vi.mock("./QrImg", () => ({ default: () => <div data-testid="qr" /> }));
vi.mock("@/components/doctor/poster/QrImg", () => ({ default: () => <div data-testid="qr" /> }));
// همان شش تخصص دکتر محمدباقر جهانتاب، با همان شکلی که API برمی‌گرداند.
const JAHANTAB = {
name: "محمدباقر جهانتاب",
specialties: [
{ id: "13", name: "جراحی عمومی", parent_id: null },
{ id: "14", name: "جراحی پلاستیک و زیبایی", parent_id: "13" },
{ id: "167", name: "جراحی لاپاراسکوپی", parent_id: "13" },
{ id: "168", name: "جراح تیروئید", parent_id: "13" },
{ id: "169", name: "جراح گوارش", parent_id: "13" },
{ id: "170", name: "جراحی سرطانها", parent_id: "13" },
],
expertise: [],
address: [],
};
describe.each([
["پوستر تیره", Poster],
["پوستر روشن", PosterLight],
])("%s", (_label, Component) => {
it("تخصص والد را به‌عنوان تیتر نشان می‌دهد", () => {
render(<Component data={JAHANTAB} />);
expect(screen.getByText("جراحی عمومی")).toBeInTheDocument();
});
it("زیرتخصص‌ها یک خط متنی‌اند، نه چیپ جداگانه", () => {
render(<Component data={JAHANTAB} />);
// اگر چیپ بودند، هر نام یک گرهٔ متنی مستقل داشت.
expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
expect(screen.getByText(/جراح گوارش/)).toBeInTheDocument();
});
it("مازاد را می‌شمارد و نمی‌برد", () => {
render(<Component data={JAHANTAB} />);
expect(screen.getByText(/تخصص دیگر/)).toBeInTheDocument();
});
it("کادر ثابت ۱۰۸۰×۱۳۵۰ و overflow-hidden دست‌نخورده است", () => {
const { container } = render(<Component data={JAHANTAB} />);
const frame = container.firstChild;
expect(frame.className).toContain("w-[1080px]");
expect(frame.className).toContain("h-[1350px]");
expect(frame.className).toContain("overflow-hidden");
});
it("خط زیرتخصص از بودجهٔ کاراکتر نمی‌گذرد", () => {
// jsdom چیدمان را اندازه نمی‌گیرد، پس سرریزِ پیکسلی اینجا اثبات‌شدنی نیست.
// چیزی که اثبات می‌شود همان سازوکاری است که سرریز را می‌بندد: طول متن کراندار.
render(<Component data={JAHANTAB} />);
const line = screen.getByText(/جراح گوارش/);
expect(line.textContent.length).toBeLessThan(120);
});
it("پزشک تک‌تخصص خط زیرتخصص ندارد", () => {
render(
<Component
data={{ ...JAHANTAB, specialties: [{ id: "13", name: "جراحی عمومی", parent_id: null }] }}
/>
);
expect(screen.getByText("جراحی عمومی")).toBeInTheDocument();
expect(screen.queryByText(/تخصص دیگر/)).not.toBeInTheDocument();
});
it("پزشک بدون تخصص کرش نمی‌کند", () => {
expect(() => render(<Component data={{ ...JAHANTAB, specialties: [] }} />)).not.toThrow();
});
});
+2 -13
View File
@@ -1,20 +1,9 @@
import specialties from "@/data/specialties.json";
import { nextSpecialtyFilter } from "@/helper";
import Form from "./form";
function Content({ filter, setFilter, updateData, setDataInURL }) {
const changeSpecialty = (name, value) => {
const newFilter = { ...filter, [name]: value };
if (name === "category") {
if (value) {
const filtered = specialties
.filter((item) => item.parent_id)
.filter((item) => String(item.parent_id) === String(value.id));
newFilter.specialty = filtered[0] || null;
} else {
newFilter.specialty = null;
}
}
const newFilter = nextSpecialtyFilter(filter, name, value);
setFilter(newFilter);
setDataInURL(newFilter);
+6 -2
View File
@@ -24,9 +24,13 @@ function Form({ filter, changeSpecialty, updateData }) {
<CateSelector
name="specialty"
list={childrenList}
updateData={updateData}
updateData={changeSpecialty}
value={filter.specialty}
label="تخصص"
// خالی‌بودن یعنی کل گروه؛ برچسب همین را می‌گوید تا کاربر فکر نکند
// چیزی را جا انداخته.
label={
filter.category ? `همه تخصص‌های ${filter.category.name}` : "تخصص"
}
/>
</div>
<div className="mt-[40px] mb-[32px]">
+1 -1
View File
@@ -55,7 +55,7 @@ function SearchField({ filter, setFilter, setDataInURL, sendReq }) {
clearText={clearText}
inputValue={filter.name}
handleSearch={handleSearch}
placeholder="جستجوی نام پزشک ..."
placeholder="جستجوی نام پزشک یا تخصص ..."
/>
</div>
);
+46 -1
View File
@@ -844,5 +844,50 @@
"status": 1,
"weight": 0,
"parent_id": 19
},
{
"id": 167,
"uuid": "86b4cebc-ca17-4c4e-9b1e-c1310ab0521f",
"name": "جراحی لاپاراسکوپی",
"slug": "جراحی-لاپاراسکوپی",
"status": 1,
"weight": 0,
"parent_id": 13
},
{
"id": 168,
"uuid": "6828d049-f0b3-421e-930a-4b6ac57c75f1",
"name": "جراح تیروئید",
"slug": "جراح-تیروئید",
"status": 1,
"weight": 0,
"parent_id": 13
},
{
"id": 169,
"uuid": "fbce44e2-d9b6-412e-a64a-bca4d27a6969",
"name": "جراح گوارش",
"slug": "جراح-گوارش",
"status": 1,
"weight": 0,
"parent_id": 13
},
{
"id": 170,
"uuid": "94fe785b-4886-4710-9619-a7d52050ed40",
"name": "جراحی سرطانها",
"slug": "جراحی-سرطانها",
"status": 1,
"weight": 0,
"parent_id": 13
},
{
"id": 171,
"uuid": "57a5cc39-602e-4e0a-80df-ffd073d8c3d2",
"name": "جراحی جنرال",
"slug": "جراحی-جنرال",
"status": 1,
"weight": 0,
"parent_id": 13
}
]
]
+41 -8
View File
@@ -267,20 +267,53 @@ export function clearDoctorClinicParams(router, 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);
const filteredChildrenList =
data && data.category
? childrenList.filter(
(item) => String(item.parent_id) === String(data.category.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: parentList,
childrenList: filteredChildrenList,
parentList,
childrenList: ofCategory.length
? [allOfCategoryOption(data.category), ...ofCategory]
: [],
};
};
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it } from "vitest";
import specialtiesData from "@/data/specialties.json";
import {
QueryForDoctorsFilter,
QueryForDoctorsReq,
filterList,
nextSpecialtyFilter,
} from "@/helper";
const byName = (name) => specialtiesData.find((s) => s.name === name);
const GENERAL_SURGERY = byName("جراحی عمومی");
describe("filterList — گزینهٔ «همه تخصص‌های X»", () => {
it("بدون گروه، فهرست زیرتخصص خالی است", () => {
expect(filterList({}).childrenList).toEqual([]);
expect(filterList(null).childrenList).toEqual([]);
});
it("با گروه، «همه» اولین گزینه است و id گروه را ندارد", () => {
const { childrenList } = filterList({ category: GENERAL_SURGERY });
expect(childrenList[0]).toMatchObject({
id: null,
isAll: true,
name: `همه تخصص‌های ${GENERAL_SURGERY.name}`,
});
});
it("زیرتخصص‌های واقعی بعد از «همه» می‌آیند", () => {
const { childrenList } = filterList({ category: GENERAL_SURGERY });
const real = childrenList.slice(1);
expect(real.length).toBeGreaterThan(0);
expect(
real.every((s) => String(s.parent_id) === String(GENERAL_SURGERY.id))
).toBe(true);
});
it("زیرتخصص‌های تازهٔ همگام‌شده هم در فهرست هستند", () => {
// این پنج تا در اسنپ‌شات دستیِ قبلی نبودند؛ اگر همگام‌سازی برگردد، اینجا قرمز می‌شود.
const names = filterList({ category: GENERAL_SURGERY }).childrenList.map(
(s) => s.name
);
expect(names).toContain("جراح گوارش");
expect(names).toContain("جراح تیروئید");
});
it("گروهِ بدون زیرشاخه گزینهٔ «همه» نمی‌گیرد", () => {
const leafRoot = specialtiesData.find(
(s) =>
!s.parent_id &&
!specialtiesData.some((c) => String(c.parent_id) === String(s.id))
);
expect(filterList({ category: leafRoot }).childrenList).toEqual([]);
});
it("فهرست گروه‌ها فقط ریشه‌هاست", () => {
expect(filterList({}).parentList.every((s) => !s.parent_id)).toBe(true);
});
});
describe("nextSpecialtyFilter — قاعدهٔ انتخاب", () => {
it("انتخاب گروه، زیرتخصص را تهی می‌گذارد نه اولین فرزند", () => {
const firstChild = filterList({ category: GENERAL_SURGERY }).childrenList[1];
const next = nextSpecialtyFilter({}, "category", GENERAL_SURGERY);
expect(next.category).toBe(GENERAL_SURGERY);
expect(next.specialty).toBeNull();
expect(next.specialty).not.toEqual(firstChild);
});
it("عوض کردن گروه، زیرتخصصِ گروه قبلی را پاک می‌کند", () => {
const child = filterList({ category: GENERAL_SURGERY }).childrenList[1];
const other = specialtiesData.find(
(s) => !s.parent_id && s.id !== GENERAL_SURGERY.id
);
const next = nextSpecialtyFilter(
{ category: GENERAL_SURGERY, specialty: child },
"category",
other
);
expect(next.specialty).toBeNull();
});
it("انتخاب «همه» زیرتخصص را تهی می‌کند", () => {
const all = filterList({ category: GENERAL_SURGERY }).childrenList[0];
const next = nextSpecialtyFilter(
{ category: GENERAL_SURGERY },
"specialty",
all
);
expect(next.specialty).toBeNull();
});
it("انتخاب یک زیرتخصص واقعی همان را می‌نشاند", () => {
const child = filterList({ category: GENERAL_SURGERY }).childrenList[1];
const next = nextSpecialtyFilter(
{ category: GENERAL_SURGERY },
"specialty",
child
);
expect(next.specialty).toBe(child);
});
it("پاک کردن گروه هر دو را تهی می‌کند", () => {
const next = nextSpecialtyFilter(
{ category: GENERAL_SURGERY, specialty: { id: 1 } },
"category",
null
);
expect(next.category).toBeNull();
expect(next.specialty).toBeNull();
});
it("فیلترهای دیگر دست‌نخورده می‌مانند", () => {
const next = nextSpecialtyFilter(
{ city: { id: 7 }, gender: "زن" },
"category",
GENERAL_SURGERY
);
expect(next.city).toEqual({ id: 7 });
expect(next.gender).toBe("زن");
});
});
describe("انتخاب کل گروه — پارامترها", () => {
it("بدون زیرتخصص، درخواست با شناسهٔ گروه می‌رود", () => {
// بک‌اند specialty_id را به همهٔ زیرشاخه‌ها گسترش می‌دهد.
const params = QueryForDoctorsReq({ category: GENERAL_SURGERY, specialty: null });
expect(params.specialty_id).toBe(GENERAL_SURGERY.id);
});
it("با زیرتخصص، همان زیرتخصص بر گروه مقدم است", () => {
const child = filterList({ category: GENERAL_SURGERY }).childrenList[1];
const params = QueryForDoctorsReq({ category: GENERAL_SURGERY, specialty: child });
expect(params.specialty_id).toBe(child.id);
});
it("URL نام گروه را می‌گیرد، نه عنوان مصنوعیِ «همه …»", () => {
// عنوان «همه تخصص‌های …» در specialties.json نیست؛ اگر در URL می‌نشست،
// با رفرش یا اشتراک لینک بازسازی نمی‌شد.
const query = QueryForDoctorsFilter({ category: GENERAL_SURGERY, specialty: null });
expect(query.specialty).toBe(GENERAL_SURGERY.name);
expect(specialtiesData.some((s) => s.name === query.specialty)).toBe(true);
});
it("نامِ رفته در URL همیشه در فایل تخصص‌ها پیدا می‌شود", () => {
const child = filterList({ category: GENERAL_SURGERY }).childrenList[1];
const query = QueryForDoctorsFilter({ category: GENERAL_SURGERY, specialty: child });
expect(specialtiesData.some((s) => s.name === query.specialty)).toBe(true);
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* قاعدهٔ مشترک نمایش تخصص‌های پزشک — منبع واحد کارت و پوستر.
*
* پزشک می‌تواند چند تخصص داشته باشد و معمولاً هم دارد: ذخیرهٔ یک زیرتخصص در
* بک‌اند والدهایش را هم می‌نشاند، پس «جراح گوارش» عملاً یعنی «جراحی عمومی» +
* زیرشاخه. چاپ همهٔ نام‌ها پشت‌سرهم، در موبایل ارتفاع کارت را باد می‌کند و در
* پوستر از کادر ثابت بیرون می‌زند.
*
* نمایش در دو جا متفاوت است — کارت `+N` کلیک‌شدنی دارد و پوستر ندارد — اما
* «کدام تخصص اصلی است» یک قاعده بیشتر نیست و اینجا می‌ماند.
*/
const SEPARATOR = " · ";
/**
* تخصص «اصلی» و بقیه.
*
* ریشه (بدون `parent_id`) اصلی است: عنوانی است که بیمار می‌شناسد و چون هنگام
* ذخیره خودکار اضافه می‌شود تقریباً همیشه وجود دارد. اگر پزشکی فقط زیرتخصص داشت،
* اولین آیتم آرایه.
*
* @param {Array<{name?: string, parent_id?: string|number|null}>} list
* @returns {{primary: object|null, rest: Array<object>}}
*/
export function splitSpecialties(list) {
const items = (list ?? []).filter((s) => s?.name);
if (items.length === 0) return { primary: null, rest: [] };
const primary = items.find((s) => s.parent_id == null) ?? items[0];
return { primary, rest: items.filter((s) => s !== primary) };
}
/**
* زیرتخصص‌ها به‌شکل یک خط متنی، بریده روی مرز کلمه با بودجهٔ کاراکتر.
*
* پوستر کادر ثابت ۱۰۸۰×۱۳۵۰ با `overflow-hidden` دارد؛ هر ردیف اضافه بخش‌های
* پایین را بیرون می‌اندازد. برش بر اساس طول متن است نه تعداد ثابت، چون
* «جراحی لاپاراسکوپی» و «قلب» یک‌اندازه جا نمی‌گیرند.
*
* @param {Array<{name?: string}>} rest
* @param {number} budget حداکثر کاراکترِ خط
* @returns {{text: string, hidden: number}}
*/
export function posterSpecialtyLine(rest, budget = 90) {
const names = (rest ?? []).filter((s) => s?.name).map((s) => s.name);
const shown = [];
let used = 0;
for (const name of names) {
const cost = name.length + (shown.length ? SEPARATOR.length : 0);
if (shown.length && used + cost > budget) break;
shown.push(name);
used += cost;
}
return { text: shown.join(SEPARATOR), hidden: names.length - shown.length };
}
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay";
const root = { id: "13", name: "جراحی عمومی", parent_id: null };
const gi = { id: "169", name: "جراح گوارش", parent_id: "13" };
const thyroid = { id: "168", name: "جراح تیروئید", parent_id: "13" };
describe("splitSpecialties", () => {
it("ریشه اصلی است، حتی اگر اول آرایه نباشد", () => {
const { primary, rest } = splitSpecialties([gi, root, thyroid]);
expect(primary).toBe(root);
expect(rest).toEqual([gi, thyroid]);
});
it("بدون ریشه، اولین آیتم اصلی است", () => {
const { primary, rest } = splitSpecialties([gi, thyroid]);
expect(primary).toBe(gi);
expect(rest).toEqual([thyroid]);
});
it("تک‌تخصص، بقیه خالی", () => {
const { primary, rest } = splitSpecialties([root]);
expect(primary).toBe(root);
expect(rest).toEqual([]);
});
it("آرایهٔ خالی و ورودی تهی → primary تهی", () => {
expect(splitSpecialties([])).toEqual({ primary: null, rest: [] });
expect(splitSpecialties(null)).toEqual({ primary: null, rest: [] });
expect(splitSpecialties(undefined)).toEqual({ primary: null, rest: [] });
});
it("آیتم بدون نام حذف می‌شود", () => {
const { primary, rest } = splitSpecialties([{ id: "1" }, root, gi]);
expect(primary).toBe(root);
expect(rest).toEqual([gi]);
});
it("parent_id تهیِ رشته‌ای هم ریشه شمرده می‌شود", () => {
// بک‌اند parent_id را رشته می‌دهد؛ برابریِ سست عمدی است تا null و undefined
// هر دو ریشه باشند و 0 نباشد.
const { primary } = splitSpecialties([
{ id: "9", name: "زیرشاخه", parent_id: "13" },
{ id: "13", name: "ریشه", parent_id: undefined },
]);
expect(primary.name).toBe("ریشه");
});
it("دو ریشهٔ متفاوت → اولی اصلی، دومی در بقیه", () => {
const internal = { id: "2", name: "داخلی", parent_id: null };
const { primary, rest } = splitSpecialties([root, internal, gi]);
expect(primary).toBe(root);
expect(rest).toEqual([internal, gi]);
});
});
describe("posterSpecialtyLine", () => {
it("نام‌ها را با جداکنندهٔ نقطه به هم می‌چسباند", () => {
const { text, hidden } = posterSpecialtyLine([gi, thyroid], 90);
expect(text).toBe("جراح گوارش · جراح تیروئید");
expect(hidden).toBe(0);
});
it("از بودجه که گذشت، بقیه را می‌شمارد نه می‌برد", () => {
const { text, hidden } = posterSpecialtyLine([gi, thyroid], 12);
expect(text).toBe("جراح گوارش");
expect(hidden).toBe(1);
});
it("برش روی مرز کلمه است، نه وسط نام", () => {
const { text } = posterSpecialtyLine([gi, thyroid], 15);
expect(text.endsWith("…")).toBe(false);
expect(text).toBe("جراح گوارش");
});
it("یک نام بلندتر از کل بودجه، بریده نمی‌شود", () => {
// خط خالی با «و ۱ تخصص دیگر» بدتر از یک نام کمی بلند است.
const long = { name: "جراحی لاپاراسکوپی پیشرفتهٔ کبد و مجاری صفراوی" };
const { text, hidden } = posterSpecialtyLine([long], 10);
expect(text).toBe(long.name);
expect(hidden).toBe(0);
});
it("آرایهٔ خالی و ورودی تهی → متن تهی و صفر پنهان", () => {
expect(posterSpecialtyLine([])).toEqual({ text: "", hidden: 0 });
expect(posterSpecialtyLine(null)).toEqual({ text: "", hidden: 0 });
});
it("آیتم بدون نام نه نمایش داده می‌شود نه شمرده", () => {
const { text, hidden } = posterSpecialtyLine([{ id: "1" }, gi], 90);
expect(text).toBe("جراح گوارش");
expect(hidden).toBe(0);
});
it("شش تخصصِ نمونهٔ واقعی از بودجهٔ پیش‌فرض نمی‌گذرد", () => {
const real = [
{ name: "جراحی پلاستیک و زیبایی" },
{ name: "جراحی لاپاراسکوپی" },
{ name: "جراح تیروئید" },
{ name: "جراح گوارش" },
{ name: "جراحی سرطانها" },
];
const { text, hidden } = posterSpecialtyLine(real);
expect(text.length).toBeLessThanOrEqual(90);
expect(hidden).toBe(real.length - text.split(" · ").length);
});
});
+1
View File
@@ -7,6 +7,7 @@
},
"scripts": {
"dev": "cross-env HOST=yazd-nobat.localhost PORT=3000 NODE_TLS_REJECT_UNAUTHORIZED=0 next dev",
"prebuild": "node scripts/sync-specialties.mjs",
"build": "next build",
"start": "next start",
"lint": "eslint .",
+80
View File
@@ -0,0 +1,80 @@
/**
* همگام‌سازی `data/specialties.json` با دیتابیس، در زمان build.
*
* node scripts/sync-specialties.mjs
*
* فایل قبلاً یک اسنپ‌شات دستی بود و عقب می‌ماند: تخصصی که ادمین می‌ساخت در سایت
* نبود، پس صفحه‌اش ۴۰۴ می‌شد، در سایت‌مپ نمی‌آمد، و breadcrumb صفحهٔ پزشک به آن
* لینک نمی‌داد. نُه مصرف‌کننده از همین فایل می‌خوانند، از `app/sitemap.js` تا
* `helper/filterList`، پس منبع باید یکی و تازه بماند.
*
* فایل عمداً حذف نشد و runtime fetch جایش نیامد: `sitemap` و صفحات تخصص باید
* ایستا بمانند و به دسترس‌بودن API در زمان درخواست گره نخورند.
*
* فقط به `build` وصل است، نه `dev` — توسعهٔ آفلاین نباید به API نیاز داشته باشد.
*
* بک‌اند محلی گواهی self-signed دارد، پس اجرای محلی مثل اسکریپت `dev` نیاز دارد:
*
* NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build
*
* این متغیر عمداً داخل `prebuild` ست نشده تا build تولیدی اعتبارسنجی TLS را از
* دست ندهد.
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const OUT = join(ROOT, 'data/specialties.json');
/**
* `.env` را Next می‌خواند، نه یک اسکریپت خام node. اینجا فقط برای اجرای محلی
* خوانده می‌شود و متغیرهای واقعیِ محیط را بازنویسی نمی‌کند — در CI فایل نیست و
* مقدار از پلتفرم می‌آید.
*/
function loadDotEnv() {
const file = join(ROOT, '.env');
if (!existsSync(file)) return;
for (const line of readFileSync(file, 'utf8').split('\n')) {
const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (!match) continue;
const [, key, raw] = match;
if (process.env[key] === undefined) {
process.env[key] = raw.replace(/^["']|["']$/g, '');
}
}
}
loadDotEnv();
const API_URL = process.env.NEXT_PUBLIC_API_URL;
if (!API_URL) {
throw new Error('sync-specialties: NEXT_PUBLIC_API_URL is not set');
}
// `GET /api/v1/specialties` عمومی است و توکن نمی‌خواهد؛ بدون پارامتر `parent_id`
// همهٔ تخصص‌های فعال را می‌دهد، ریشه‌ها و فرزندان با هم. پاسخ دولایه است.
const res = await fetch(`${API_URL}/api/v1/specialties`);
if (!res.ok) {
throw new Error(`sync-specialties: HTTP ${res.status} from ${API_URL}`);
}
const items = (await res.json())?.data?.data;
// لیست خالی یعنی چیزی غلط است — پاسخ عوض شده، دیتابیس خالی است، یا پشت پراکسی
// نشسته‌ایم. بازنویسی با آن، سایت‌مپ را آب می‌کند و صفحات تخصص را از ایندکس
// می‌اندازد؛ شکستِ build از آن به‌مراتب ارزان‌تر است.
if (!Array.isArray(items) || items.length === 0) {
throw new Error('sync-specialties: empty list; refusing to overwrite data/specialties.json');
}
// ترتیب پایدار بر اساس id تا diff فایل نویزی نشود و مرور تغییرات ممکن بماند.
const sorted = [...items].sort((a, b) => a.id - b.id);
const before = JSON.parse(readFileSync(OUT, 'utf8'));
writeFileSync(OUT, `${JSON.stringify(sorted, null, 2)}\n`, 'utf8');
console.log(`sync-specialties: ${before.length}${sorted.length} specialties from ${API_URL}`);