+ {/* ستون نوبتدهی باریک است؛ همان الگوی کارت پزشک — تخصص اصلی و
+ شمارندهٔ بقیه — تا شش نام پشتسرهم ارتفاعش را چند برابر نکند. */}
+
diff --git a/components/doctor/detailDoctor/Title.js b/components/doctor/detailDoctor/Title.js
index b5e8841..94d7a5f 100644
--- a/components/doctor/detailDoctor/Title.js
+++ b/components/doctor/detailDoctor/Title.js
@@ -46,12 +46,20 @@ function Title({ doctor }) {
-
- تخصص:
- {doctor?.specialties?.map(
- (item, idx) =>
- `${item.name} ${doctor.specialties.length === idx + 1 ? "" : "|"} `
- )}
+ {/* اینجا جای فهرست کاملِ تخصصهاست — سربرگ صفحه فقط تخصص اصلی را نشان
+ میدهد. چیپهای شکننده جای رشتهٔ درهم نشستند: پیش از این خروجی
+ «تخصص:جراحی عمومی | … | » بود، بدون فاصله بعد از دونقطه و با
+ جداکنندهٔ آویزان در انتها. */}
+
+ {/* این خط یک breadcrumb است، نه فهرست تخصص. چاپ هر شش تخصص با
+ text-nowrap در موبایل از عرض صفحه بیرون میزد. فهرست کامل چند خط
+ پایینتر در Title.js میآید و آنجا میشکند. */}
+ {primarySpecialty && (
+
+
+ {primarySpecialty.name}
+
+ {">"}
+
+ )}
{doctorTitle(doctor?.name)}
diff --git a/components/doctor/specialtyText.test.js b/components/doctor/specialtyText.test.js
new file mode 100644
index 0000000..40625e8
--- /dev/null
+++ b/components/doctor/specialtyText.test.js
@@ -0,0 +1,75 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import Title from "@/components/doctor/detailDoctor/Title";
+import ItemAppointment from "@/components/doctor/appointmentList/ItemAppointment";
+
+vi.mock("@/components/doctor/detailDoctor/Share", () => ({ default: () => null }));
+
+// همان شش تخصص دکتر محمدباقر جهانتاب.
+const 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" },
+];
+
+const DOCTOR = { name: "محمدباقر جهانتاب", specialties: SPECIALTIES, experience: 5 };
+
+describe("بدنهٔ پروفایل — فهرست کامل تخصصها", () => {
+ it("هر شش تخصص را جدا نشان میدهد، نه یک رشتهٔ درهم", () => {
+ render();
+
+ for (const s of SPECIALTIES) {
+ expect(screen.getByText(s.name)).toBeInTheDocument();
+ }
+ });
+
+ it("بعد از «تخصص:» نام نمیچسبد", () => {
+ // باگ قبلی: خروجی «تخصص:جراحی عمومی» بود.
+ render();
+
+ expect(screen.getByText("تخصص:")).toBeInTheDocument();
+ expect(screen.queryByText(/تخصص:جراحی/)).not.toBeInTheDocument();
+ });
+
+ it("جداکنندهٔ آویزان در انتها ندارد", () => {
+ const { container } = render();
+
+ expect(container.textContent).not.toMatch(/\|\s*$/);
+ expect(container.textContent).not.toContain("|");
+ });
+
+ it("پزشک بدون تخصص فقط برچسب را نشان میدهد و کرش نمیکند", () => {
+ expect(() =>
+ render()
+ ).not.toThrow();
+ });
+});
+
+describe("ستون نوبتدهی — تخصص اصلی و شمارنده", () => {
+ const turn = { ...DOCTOR, uuid: "u-1", specialties: SPECIALTIES };
+
+ it("فقط تخصص اصلی و شمارندهٔ بقیه را نشان میدهد", () => {
+ render();
+
+ expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "نمایش 5 تخصص دیگر" })
+ ).toBeInTheDocument();
+ expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
+ });
+
+ it("جداکنندهٔ آویزان ندارد", () => {
+ const { container } = render();
+
+ expect(container.textContent).not.toContain("|");
+ });
+
+ it("پزشک بدون تخصص کرش نمیکند", () => {
+ expect(() =>
+ render()
+ ).not.toThrow();
+ });
+});
diff --git a/components/specialties/detail/SpecialtyTree.js b/components/specialties/detail/SpecialtyTree.js
new file mode 100644
index 0000000..c826f8b
--- /dev/null
+++ b/components/specialties/detail/SpecialtyTree.js
@@ -0,0 +1,62 @@
+import Link from "next/link";
+import { childSpecialties, parentSpecialty } from "@/lib/specialtyIndexing";
+
+const CHIP =
+ "px-[14px] py-[8px] rounded-lg bg-[#FFF] border border-[#EFEFEF] text-[14px] font-medium text-[#525252] hover:bg-[#5559CE] hover:text-[#FFF] hover:border-[#5559CE] transition-all duration-500";
+
+const CHIP_ACTIVE =
+ "px-[14px] py-[8px] rounded-lg bg-[#5559CE] border border-[#5559CE] text-[14px] font-medium text-[#FFF]";
+
+/**
+ * ناوبری والد/فرزندِ صفحهٔ تخصص.
+ *
+ * تا پیش از این، صفحهٔ «جراحی عمومی» هیچ نشانی از زیرشاخههایش نداشت و صفحهٔ
+ * «جراح گوارش» راهی به گروهش. کاربر برای دیدن گروه باید URL دستکاری میکرد.
+ *
+ * روی صفحهٔ والد، «همه تخصصهای X» حالت جاری است نه یک لینک دیگر: بکاند
+ * `specialty_id` را به همهٔ نوادگان گسترش میدهد، پس همین صفحه از قبل همهٔ پزشکان
+ * گروه را نشان میدهد. برچسبِ فعال همین را صریح میکند تا کاربر دنبال گزینه نگردد.
+ */
+function SpecialtyTree({ specialty }) {
+ const parent = parentSpecialty(specialty);
+ const siblingsOf = parent ?? specialty;
+ const children = childSpecialties(siblingsOf);
+
+ if (children.length === 0) return null;
+
+ const allHref = `/specialties/${siblingsOf.slug}`;
+ const isAllActive = !parent;
+
+ return (
+
+ );
+}
+
+export default SpecialtyTree;
diff --git a/components/specialties/detail/SpecialtyTree.test.js b/components/specialties/detail/SpecialtyTree.test.js
new file mode 100644
index 0000000..2d41799
--- /dev/null
+++ b/components/specialties/detail/SpecialtyTree.test.js
@@ -0,0 +1,90 @@
+import { describe, expect, it } from "vitest";
+import { render, screen } from "@testing-library/react";
+import specialtiesData from "@/data/specialties.json";
+import { childSpecialties, parentSpecialty } from "@/lib/specialtyIndexing";
+import SpecialtyTree from "@/components/specialties/detail/SpecialtyTree";
+
+const byName = (name) => specialtiesData.find((s) => s.name === name);
+
+const GENERAL_SURGERY = byName("جراحی عمومی");
+const GI = byName("جراح گوارش");
+
+describe("childSpecialties / parentSpecialty", () => {
+ it("والد فرزندان فعالش را میدهد", () => {
+ const names = childSpecialties(GENERAL_SURGERY).map((s) => s.name);
+
+ expect(names).toContain("جراح گوارش");
+ expect(names).toContain("جراحی پلاستیک و زیبایی");
+ });
+
+ it("برگ فرزندی ندارد", () => {
+ expect(childSpecialties(GI)).toEqual([]);
+ });
+
+ it("زیرتخصص والدش را میدهد، ریشه null", () => {
+ expect(parentSpecialty(GI)?.id).toBe(GENERAL_SURGERY.id);
+ expect(parentSpecialty(GENERAL_SURGERY)).toBeNull();
+ });
+
+ it("ورودی تهی کرش نمیکند", () => {
+ expect(childSpecialties(null)).toEqual([]);
+ expect(parentSpecialty(undefined)).toBeNull();
+ });
+
+ it("ترتیب فرزندان پایدار است", () => {
+ const once = childSpecialties(GENERAL_SURGERY).map((s) => s.id);
+ const twice = childSpecialties(GENERAL_SURGERY).map((s) => s.id);
+
+ expect(once).toEqual(twice);
+ });
+});
+
+describe("SpecialtyTree", () => {
+ it("روی صفحهٔ والد، «همه تخصصهای X» حالت جاری است نه لینک", () => {
+ render();
+
+ const all = screen.getByText(`همه تخصصهای ${GENERAL_SURGERY.name}`);
+ expect(all).toHaveAttribute("aria-current", "page");
+ expect(all.tagName).not.toBe("A");
+ });
+
+ it("روی صفحهٔ والد، همهٔ زیرتخصصها لینک دارند", () => {
+ render();
+
+ const link = screen.getByRole("link", { name: "جراح گوارش" });
+ expect(link).toHaveAttribute("href", `/specialties/${GI.slug}`);
+ });
+
+ it("روی صفحهٔ زیرتخصص، «همه» لینکِ بازگشت به گروه است", () => {
+ render();
+
+ const back = screen.getByRole("link", {
+ name: `همه تخصصهای ${GENERAL_SURGERY.name}`,
+ });
+ expect(back).toHaveAttribute("href", `/specialties/${GENERAL_SURGERY.slug}`);
+ });
+
+ it("روی صفحهٔ زیرتخصص، خودش حالت جاری است و خواهرانش لینک", () => {
+ render();
+
+ expect(screen.getByText(GI.name)).toHaveAttribute("aria-current", "page");
+ expect(screen.getByRole("link", { name: "جراح تیروئید" })).toBeInTheDocument();
+ });
+
+ it("تخصص بدون خواهر و فرزند چیزی رندر نمیکند", () => {
+ const lonely = specialtiesData.find(
+ (s) => !s.parent_id && childSpecialties(s).length === 0
+ );
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("ناوبری برچسب دسترسیپذیر دارد", () => {
+ render();
+
+ expect(
+ screen.getByRole("navigation", { name: `زیرتخصصهای ${GENERAL_SURGERY.name}` })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/components/specialties/detail/breadcrumb.test.js b/components/specialties/detail/breadcrumb.test.js
new file mode 100644
index 0000000..c9e09b5
--- /dev/null
+++ b/components/specialties/detail/breadcrumb.test.js
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+import { render, screen } from "@testing-library/react";
+import specialtiesData from "@/data/specialties.json";
+import SpecialtyDetailPage from "@/components/specialties/detail";
+
+const byName = (name) => specialtiesData.find((s) => s.name === name);
+
+const GENERAL_SURGERY = byName("جراحی عمومی");
+const GI = byName("جراح گوارش");
+
+const renderPage = (specialty) =>
+ render(
+
+ );
+
+/** حلقههای breadcrumd بهترتیب. */
+const crumbs = () =>
+ Array.from(
+ screen.getByRole("navigation", { name: "breadcrumb" }).querySelectorAll("li")
+ ).map((li) => li.textContent.replace(/>/g, "").trim());
+
+describe("breadcrumb صفحهٔ تخصص", () => {
+ it("صفحهٔ زیرتخصص حلقهٔ والد را دارد", () => {
+ renderPage(GI);
+
+ expect(crumbs()).toEqual(["خانه", "تخصصها", GENERAL_SURGERY.name, GI.name]);
+ });
+
+ it("حلقهٔ والد به صفحهٔ گروه لینک میدهد", () => {
+ renderPage(GI);
+
+ expect(
+ screen.getByRole("link", { name: GENERAL_SURGERY.name })
+ ).toHaveAttribute("href", `/specialties/${GENERAL_SURGERY.slug}`);
+ });
+
+ it("صفحهٔ ریشه حلقهٔ والد اضافه نمیگیرد", () => {
+ renderPage(GENERAL_SURGERY);
+
+ expect(crumbs()).toEqual(["خانه", "تخصصها", GENERAL_SURGERY.name]);
+ });
+
+ it("JSON-LD همان حلقهها را دارد — نما و schema واگرا نمیشوند", () => {
+ const { container } = renderPage(GI);
+
+ const script = container.querySelector('script[type="application/ld+json"]');
+ const jsonLd = JSON.parse(script.innerHTML);
+
+ expect(jsonLd["@type"]).toBe("BreadcrumbList");
+ expect(jsonLd.itemListElement.map((i) => i.name)).toEqual([
+ "خانه",
+ "تخصصها",
+ GENERAL_SURGERY.name,
+ GI.name,
+ ]);
+ });
+});
diff --git a/components/specialties/detail/index.js b/components/specialties/detail/index.js
index fe1ebe3..e394cb1 100644
--- a/components/specialties/detail/index.js
+++ b/components/specialties/detail/index.js
@@ -1,10 +1,14 @@
import Pageguide from "@/app/component/Pageguide";
+import { parentSpecialty } from "@/lib/specialtyIndexing";
import DoctorGrid from "./DoctorGrid";
import Faq from "./Faq";
+import SpecialtyTree from "./SpecialtyTree";
// چیدمان عمداً همخانوادهٔ /doctors است: همان padding-responsive، همان فاصلهٔ بالای صفحه،
// همان تایپوگرافی عنوان و همان گرید کارت پزشک — کاربر نباید حس کند وارد بخش دیگری شده.
-function SpecialtyDetailPage({ specialtyName, cityName, doctors, intro, faq, origin, slug }) {
+function SpecialtyDetailPage({ specialty, specialtyName, cityName, doctors, intro, faq, origin, slug }) {
+ const parent = parentSpecialty(specialty);
+
return (
+
+
{intro}
diff --git a/lib/specialtyIndexing.js b/lib/specialtyIndexing.js
index aa88de3..5d7a77a 100644
--- a/lib/specialtyIndexing.js
+++ b/lib/specialtyIndexing.js
@@ -25,3 +25,27 @@ export function isCategorySpecialty(specialty) {
export function isIndexableSpecialty(specialty) {
return Boolean(specialty) && !isCategorySpecialty(specialty);
}
+
+/** زیرشاخههای فعالِ یک تخصص، به ترتیب weight سپس نام. */
+export function childSpecialties(specialty) {
+ if (!specialty) return [];
+
+ return specialtiesData
+ .filter(
+ (item) =>
+ item?.status === 1 &&
+ String(item.parent_id ?? "") === String(specialty.id)
+ )
+ .sort((a, b) => (a.weight ?? 0) - (b.weight ?? 0) || a.name.localeCompare(b.name, "fa"));
+}
+
+/** والدِ فعالِ یک زیرتخصص، یا null اگر ریشه باشد. */
+export function parentSpecialty(specialty) {
+ if (!specialty?.parent_id) return null;
+
+ return (
+ specialtiesData.find(
+ (item) => item?.status === 1 && String(item.id) === String(specialty.parent_id)
+ ) ?? null
+ );
+}