diff --git a/app/component/SpecialtyChips.js b/app/component/SpecialtyChips.js
new file mode 100644
index 0000000..9b5befd
--- /dev/null
+++ b/app/component/SpecialtyChips.js
@@ -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);
+
+ // کارت داخل
است؛ بدون این دو، هر کلیک روی شمارنده صفحهٔ پزشک را باز میکند.
+ const handleOpen = (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handleClose = (event) => {
+ event?.preventDefault?.();
+ event?.stopPropagation?.();
+ setAnchorEl(null);
+ };
+
+ return (
+
+
+ تخصص: {primary.name}
+
+
+ {rest.length > 0 && (
+ <>
+
+ +{rest.length}
+
+
+
+
+ {[primary, ...rest].map((item) => (
+
+ {item.name}
+
+ ))}
+
+
+ >
+ )}
+
+ );
+}
+
+export default SpecialtyChips;
diff --git a/app/component/SpecialtyChips.test.js b/app/component/SpecialtyChips.test.js
new file mode 100644
index 0000000..47c4c09
--- /dev/null
+++ b/app/component/SpecialtyChips.test.js
@@ -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(
);
+
+ expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "نمایش 2 تخصص دیگر" })).toHaveTextContent("+2");
+
+ // بقیه تا پیش از کلیک در DOM نیستند — همین کارت را کوتاه نگه میدارد.
+ expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
+ });
+
+ it("کلیک روی شمارنده فهرست کامل را باز میکند", () => {
+ render(
);
+
+ fireEvent.click(screen.getByRole("button", { name: "نمایش 2 تخصص دیگر" }));
+
+ expect(screen.getByText("جراح گوارش")).toBeInTheDocument();
+ expect(screen.getByText("جراح تیروئید")).toBeInTheDocument();
+ // تخصص اصلی هم در فهرست هست تا تصویر کامل باشد.
+ expect(screen.getAllByText(/جراحی عمومی/).length).toBeGreaterThan(1);
+ });
+
+ it("کلیک روی شمارنده نباید لینک کارت را فعال کند", () => {
+ // کارت داخل
است؛ بدون preventDefault/stopPropagation هر بار که کاربر
+ // تخصصها را میبیند به صفحهٔ پزشک پرت میشود.
+ const onParentClick = vi.fn();
+
+ render(
+
+
+
+ );
+
+ // fireEvent مقدار false میدهد وقتی preventDefault صدا زده شده باشد.
+ const notPrevented = fireEvent.click(
+ screen.getByRole("button", { name: "نمایش 1 تخصص دیگر" })
+ );
+
+ expect(notPrevented).toBe(false);
+ expect(onParentClick).not.toHaveBeenCalled();
+ });
+
+ it("تکتخصص هیچ شمارندهای ندارد", () => {
+ render(
);
+
+ expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ it("پزشک بدون تخصص چیزی رندر نمیکند و کرش نمیکند", () => {
+ const { container: empty } = render(
);
+ expect(empty).toBeEmptyDOMElement();
+
+ const { container: missing } = render(
);
+ expect(missing).toBeEmptyDOMElement();
+ });
+
+ it("شمارنده متن دارد نه فقط علامت — برای صفحهخوان", () => {
+ render(
);
+
+ const button = screen.getByRole("button");
+ expect(button).toHaveAttribute("type", "button");
+ expect(button).toHaveAccessibleName("نمایش 2 تخصص دیگر");
+ });
+});
diff --git a/components/doctor/poster/PosterLight.js b/components/doctor/poster/PosterLight.js
index 37f3d9d..bdcd6c8 100644
--- a/components/doctor/poster/PosterLight.js
+++ b/components/doctor/poster/PosterLight.js
@@ -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)}
-
- {specialties.map((s, i) => (
-
+ {primary && (
+
+
- {s.name}
+ {primary.name}
- ))}
-
+
+ )}
+
+ {/* زیرتخصصها یک خط متنی — در چاپ نه کلیک هست نه Popover. */}
+ {subSpecialties.text && (
+
+ {subSpecialties.text}
+ {subSpecialties.hidden > 0 && ` و ${subSpecialties.hidden} تخصص دیگر`}
+
+ )}
{badges.length > 0 && (
diff --git a/components/doctor/poster/index.js b/components/doctor/poster/index.js
index 592f72f..99ce4a7 100644
--- a/components/doctor/poster/index.js
+++ b/components/doctor/poster/index.js
@@ -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 }) {
{doctorTitle(data?.name)}
-
- {specialties.map((s, i) => (
-
+ {primary && (
+
+
- {s.name}
+ {primary.name}
- ))}
-
+
+ )}
+
+ {/* زیرتخصصها یک خط متنیاند نه چیپ: در چاپ نه کلیک هست نه Popover، پس
+ «+N» بیمعناست و متن خوانا جایش را میگیرد. max-h تور ایمنیِ کادر است
+ اگر بودجهٔ کاراکتر روزی کوتاه تنظیم شود. */}
+ {subSpecialties.text && (
+
+ {subSpecialties.text}
+ {subSpecialties.hidden > 0 && ` و ${subSpecialties.hidden} تخصص دیگر`}
+
+ )}
{badges.map((b, i) => (
({
+ getStateInfoClient: () => ({ matchedCity: { site_name: "یاسوج نوبت", domain: "yasuj-nobat.ir" } }),
+}));
+
+vi.mock("./QrImg", () => ({ default: () =>
}));
+vi.mock("@/components/doctor/poster/QrImg", () => ({ default: () =>
}));
+
+// همان شش تخصص دکتر محمدباقر جهانتاب، با همان شکلی که 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( );
+
+ expect(screen.getByText("جراحی عمومی")).toBeInTheDocument();
+ });
+
+ it("زیرتخصصها یک خط متنیاند، نه چیپ جداگانه", () => {
+ render( );
+
+ // اگر چیپ بودند، هر نام یک گرهٔ متنی مستقل داشت.
+ expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
+ expect(screen.getByText(/جراح گوارش/)).toBeInTheDocument();
+ });
+
+ it("مازاد را میشمارد و نمیبرد", () => {
+ render( );
+
+ expect(screen.getByText(/تخصص دیگر/)).toBeInTheDocument();
+ });
+
+ it("کادر ثابت ۱۰۸۰×۱۳۵۰ و overflow-hidden دستنخورده است", () => {
+ const { container } = render( );
+ 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( );
+
+ const line = screen.getByText(/جراح گوارش/);
+ expect(line.textContent.length).toBeLessThan(120);
+ });
+
+ it("پزشک تکتخصص خط زیرتخصص ندارد", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("جراحی عمومی")).toBeInTheDocument();
+ expect(screen.queryByText(/تخصص دیگر/)).not.toBeInTheDocument();
+ });
+
+ it("پزشک بدون تخصص کرش نمیکند", () => {
+ expect(() => render( )).not.toThrow();
+ });
+});
diff --git a/components/doctors/modal/Content.js b/components/doctors/modal/Content.js
index 593ac24..cca1b59 100644
--- a/components/doctors/modal/Content.js
+++ b/components/doctors/modal/Content.js
@@ -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);
diff --git a/components/doctors/modal/form/index.js b/components/doctors/modal/form/index.js
index 1642968..6280f54 100644
--- a/components/doctors/modal/form/index.js
+++ b/components/doctors/modal/form/index.js
@@ -24,9 +24,13 @@ function Form({ filter, changeSpecialty, updateData }) {
diff --git a/components/doctors/search/SearchField.js b/components/doctors/search/SearchField.js
index 8dd3569..406c53a 100644
--- a/components/doctors/search/SearchField.js
+++ b/components/doctors/search/SearchField.js
@@ -55,7 +55,7 @@ function SearchField({ filter, setFilter, setDataInURL, sendReq }) {
clearText={clearText}
inputValue={filter.name}
handleSearch={handleSearch}
- placeholder="جستجوی نام پزشک ..."
+ placeholder="جستجوی نام پزشک یا تخصص ..."
/>
);
diff --git a/data/specialties.json b/data/specialties.json
index ba463aa..8e9ca07 100644
--- a/data/specialties.json
+++ b/data/specialties.json
@@ -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
}
-]
\ No newline at end of file
+]
diff --git a/helper/index.js b/helper/index.js
index bd8e52d..9a4832a 100644
--- a/helper/index.js
+++ b/helper/index.js
@@ -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]
+ : [],
};
};
diff --git a/helper/specialtyFilter.test.js b/helper/specialtyFilter.test.js
new file mode 100644
index 0000000..af3f407
--- /dev/null
+++ b/helper/specialtyFilter.test.js
@@ -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);
+ });
+});
diff --git a/lib/specialtyDisplay.js b/lib/specialtyDisplay.js
new file mode 100644
index 0000000..50ab45a
--- /dev/null
+++ b/lib/specialtyDisplay.js
@@ -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
}}
+ */
+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 };
+}
diff --git a/lib/specialtyDisplay.test.js b/lib/specialtyDisplay.test.js
new file mode 100644
index 0000000..f147fde
--- /dev/null
+++ b/lib/specialtyDisplay.test.js
@@ -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);
+ });
+});
diff --git a/package.json b/package.json
index 37b2b5e..8d50bca 100644
--- a/package.json
+++ b/package.json
@@ -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 .",
diff --git a/scripts/sync-specialties.mjs b/scripts/sync-specialties.mjs
new file mode 100644
index 0000000..6020619
--- /dev/null
+++ b/scripts/sync-specialties.mjs
@@ -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}`);