From 79747c443ca71c40b18fddb0409cce9716d950c4 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 8 Aug 2026 17:33:13 +0330 Subject: [PATCH] feat: Enhance specialty handling and navigation across doctor and specialty pages --- app/doctor/[slug]/page.js | 6 +- app/specialties/[slug]/page.js | 1 + .../doctor/appointmentList/ItemAppointment.js | 11 +-- components/doctor/detailDoctor/Title.js | 20 +++-- components/doctor/doctorHeader.test.js | 66 ++++++++++++++ components/doctor/index.js | 29 +++--- components/doctor/specialtyText.test.js | 75 ++++++++++++++++ .../specialties/detail/SpecialtyTree.js | 62 +++++++++++++ .../specialties/detail/SpecialtyTree.test.js | 90 +++++++++++++++++++ .../specialties/detail/breadcrumb.test.js | 66 ++++++++++++++ components/specialties/detail/index.js | 12 ++- lib/specialtyIndexing.js | 24 +++++ 12 files changed, 435 insertions(+), 27 deletions(-) create mode 100644 components/doctor/doctorHeader.test.js create mode 100644 components/doctor/specialtyText.test.js create mode 100644 components/specialties/detail/SpecialtyTree.js create mode 100644 components/specialties/detail/SpecialtyTree.test.js create mode 100644 components/specialties/detail/breadcrumb.test.js diff --git a/app/doctor/[slug]/page.js b/app/doctor/[slug]/page.js index bbb1d5f..a646908 100644 --- a/app/doctor/[slug]/page.js +++ b/app/doctor/[slug]/page.js @@ -7,6 +7,7 @@ import { getEntityOrigin } from "@/lib/getCanonicalUrl"; import { extractEntityCityId, findCityById } from "@/lib/domainHelpers"; import { buildDoctorFaq } from "@/lib/specialtyContent"; import { isNoindexDoctor } from "@/lib/entityQuality"; +import { splitSpecialties } from "@/lib/specialtyDisplay"; import specialtiesData from "@/data/specialties.json"; import { safeJsonLd } from "@/lib/sanitize"; import { imageUrl, doctorTitle } from "@/helper"; @@ -246,7 +247,10 @@ async function Doctor({ params }) { } : null; - const specialtyLabel = doctor?.specialties?.[0]?.name || "پزشکان"; + // همان تخصصی که سربرگ صفحه نشان می‌دهد؛ اگر اینجا اولین آیتم آرایه می‌ماند، + // BreadcrumbList و نمای صفحه دو چیز متفاوت می‌گفتند. + const specialtyLabel = + splitSpecialties(doctor?.specialties).primary?.name || "پزشکان"; // مقصد تمیز صفحهٔ فرود تخصص؛ اگر تخصص slug نداشت، به لیست پزشکان برمی‌گردیم. const specialtySlug = specialtiesData.find( (item) => item.name === specialtyLabel && item.status === 1 diff --git a/app/specialties/[slug]/page.js b/app/specialties/[slug]/page.js index 5d37a80..942f777 100644 --- a/app/specialties/[slug]/page.js +++ b/app/specialties/[slug]/page.js @@ -135,6 +135,7 @@ async function SpecialtyDetail({ params }) { /> {turn?.name}

-

- تخصص: - {turn?.specialties?.map( - (item, idx) => - `${item.name} ${turn.specialties.length === idx + 1 ? "" : "|"} ` - )} -

+ {/* ستون نوبت‌دهی باریک است؛ همان الگوی کارت پزشک — تخصص اصلی و + شمارندهٔ بقیه — تا شش نام پشت‌سرهم ارتفاعش را چند برابر نکند. */} +
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 ? "" : "|"} ` - )} + {/* اینجا جای فهرست کاملِ تخصص‌هاست — سربرگ صفحه فقط تخصص اصلی را نشان + می‌دهد. چیپ‌های شکننده جای رشتهٔ درهم نشستند: پیش از این خروجی + «تخصص:جراحی عمومی | … | » بود، بدون فاصله بعد از دونقطه و با + جداکنندهٔ آویزان در انتها. */} +

+ تخصص: + {doctor?.specialties?.map((item) => ( + + {item.name} + + ))}

diff --git a/components/doctor/doctorHeader.test.js b/components/doctor/doctorHeader.test.js new file mode 100644 index 0000000..de542d0 --- /dev/null +++ b/components/doctor/doctorHeader.test.js @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import DoctorPage from "@/components/doctor"; + +vi.mock("@/components/doctor/appointmentList", () => ({ default: () => null })); +vi.mock("@/components/doctor/detailDoctor", () => ({ default: () => null })); +vi.mock("@/components/doctor/claim", () => ({ default: () => null })); +vi.mock("@/components/doctor/detailDoctor/Share", () => ({ default: () => null })); +vi.mock("@/components/specialties/detail/Faq", () => ({ default: () => null })); + +// همان شش تخصص دکتر محمدباقر جهانتاب. +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" }, + ], +}; + +describe("سربرگ صفحهٔ پزشک", () => { + it("فقط تخصص اصلی را نشان می‌دهد، نه هر شش تا", () => { + render(); + + expect(screen.getByRole("link", { name: "جراحی عمومی" })).toBeInTheDocument(); + // بقیه اینجا نمی‌آیند؛ فهرست کامل در بدنهٔ صفحه است و آنجا می‌شکند. + expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument(); + expect(screen.queryByText("جراحی سرطانها")).not.toBeInTheDocument(); + }); + + it("تخصص اصلی به صفحهٔ فرود همان تخصص لینک می‌دهد", () => { + render(); + + expect(screen.getByRole("link", { name: "جراحی عمومی" })).toHaveAttribute( + "href", + "/specialties/general-surgery" + ); + }); + + it("نام پزشک با عنوان نمایش داده می‌شود", () => { + render(); + + expect(screen.getByText(/محمدباقر جهانتاب/)).toBeInTheDocument(); + }); + + it("پزشکِ فقط-زیرتخصص، همان زیرتخصص را در سربرگ می‌گیرد", () => { + render( + + ); + + expect(screen.getByRole("link", { name: "جراح گوارش" })).toBeInTheDocument(); + }); + + it("پزشک بدون تخصص سربرگ خالی می‌گیرد و کرش نمی‌کند", () => { + expect(() => + render() + ).not.toThrow(); + + expect(screen.getByText(/محمدباقر جهانتاب/)).toBeInTheDocument(); + }); +}); diff --git a/components/doctor/index.js b/components/doctor/index.js index 7f13cf1..f3e96a9 100644 --- a/components/doctor/index.js +++ b/components/doctor/index.js @@ -6,6 +6,7 @@ import Share from "./detailDoctor/Share"; import CustomLoading from "@/app/component/loading/Custom"; import Faq from "@/components/specialties/detail/Faq"; import specialtiesData from "@/data/specialties.json"; +import { splitSpecialties } from "@/lib/specialtyDisplay"; import { doctorTitle } from "@/helper"; // مقصد تمیز صفحهٔ فرود تخصص؛ اگر تخصص slug نداشت، لیست پزشکان با کوئری قدیمی. @@ -15,23 +16,27 @@ const specialtyHref = (name) => { }; function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddressUuids = [], bookingLocations = [], slug, faq = [] }) { + const { primary: primarySpecialty } = splitSpecialties(doctor?.specialties); + return (
-

- {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(<Title doctor={DOCTOR} />); + + expect(screen.getByText("تخصص:")).toBeInTheDocument(); + expect(screen.queryByText(/تخصص:جراحی/)).not.toBeInTheDocument(); + }); + + it("جداکنندهٔ آویزان در انتها ندارد", () => { + const { container } = render(<Title doctor={DOCTOR} />); + + expect(container.textContent).not.toMatch(/\|\s*$/); + expect(container.textContent).not.toContain("|"); + }); + + it("پزشک بدون تخصص فقط برچسب را نشان می‌دهد و کرش نمی‌کند", () => { + expect(() => + render(<Title doctor={{ ...DOCTOR, specialties: [] }} />) + ).not.toThrow(); + }); +}); + +describe("ستون نوبت‌دهی — تخصص اصلی و شمارنده", () => { + const turn = { ...DOCTOR, uuid: "u-1", specialties: SPECIALTIES }; + + it("فقط تخصص اصلی و شمارندهٔ بقیه را نشان می‌دهد", () => { + render(<ItemAppointment turn={turn} doctorSlug="u-1" />); + + expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "نمایش 5 تخصص دیگر" }) + ).toBeInTheDocument(); + expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument(); + }); + + it("جداکنندهٔ آویزان ندارد", () => { + const { container } = render(<ItemAppointment turn={turn} doctorSlug="u-1" />); + + expect(container.textContent).not.toContain("|"); + }); + + it("پزشک بدون تخصص کرش نمی‌کند", () => { + expect(() => + render(<ItemAppointment turn={{ ...turn, specialties: [] }} doctorSlug="u-1" />) + ).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 ( + <nav + aria-label={`زیرتخصص‌های ${siblingsOf.name}`} + className="mt-[20px] flex flex-wrap items-center gap-[8px]" + > + {isAllActive ? ( + <span className={CHIP_ACTIVE} aria-current="page"> + همه تخصص‌های {siblingsOf.name} + </span> + ) : ( + <Link href={allHref} className={CHIP}> + همه تخصص‌های {siblingsOf.name} + </Link> + )} + + {children.map((child) => { + const isActive = String(child.id) === String(specialty.id); + + return isActive ? ( + <span key={child.id} className={CHIP_ACTIVE} aria-current="page"> + {child.name} + </span> + ) : ( + <Link key={child.id} href={`/specialties/${child.slug}`} className={CHIP}> + {child.name} + </Link> + ); + })} + </nav> + ); +} + +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(<SpecialtyTree specialty={GENERAL_SURGERY} />); + + const all = screen.getByText(`همه تخصص‌های ${GENERAL_SURGERY.name}`); + expect(all).toHaveAttribute("aria-current", "page"); + expect(all.tagName).not.toBe("A"); + }); + + it("روی صفحهٔ والد، همهٔ زیرتخصص‌ها لینک دارند", () => { + render(<SpecialtyTree specialty={GENERAL_SURGERY} />); + + const link = screen.getByRole("link", { name: "جراح گوارش" }); + expect(link).toHaveAttribute("href", `/specialties/${GI.slug}`); + }); + + it("روی صفحهٔ زیرتخصص، «همه» لینکِ بازگشت به گروه است", () => { + render(<SpecialtyTree specialty={GI} />); + + const back = screen.getByRole("link", { + name: `همه تخصص‌های ${GENERAL_SURGERY.name}`, + }); + expect(back).toHaveAttribute("href", `/specialties/${GENERAL_SURGERY.slug}`); + }); + + it("روی صفحهٔ زیرتخصص، خودش حالت جاری است و خواهرانش لینک", () => { + render(<SpecialtyTree specialty={GI} />); + + 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(<SpecialtyTree specialty={lonely} />); + + expect(container).toBeEmptyDOMElement(); + }); + + it("ناوبری برچسب دسترسی‌پذیر دارد", () => { + render(<SpecialtyTree specialty={GENERAL_SURGERY} />); + + 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( + <SpecialtyDetailPage + specialty={specialty} + specialtyName={specialty.name} + cityName="یاسوج" + doctors={[]} + intro="متن مقدمه" + faq={[]} + origin="https://yasuj-nobat.ir" + slug={specialty.slug} + /> + ); + +/** حلقه‌های 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 ( <div className="pt-[95px] sm:pt-[120px] padding-responsive"> <Pageguide @@ -12,6 +16,10 @@ function SpecialtyDetailPage({ specialtyName, cityName, doctors, intro, faq, ori list={[ { name: "خانه", href: "/" }, { name: "تخصص‌ها", href: "/specialties" }, + // زیرتخصص از دل گروهش می‌آید؛ بدون این حلقه، مسیر بازگشت به گروه فقط + // دستکاری URL بود. Pageguide همین فهرست را به BreadcrumbList هم می‌دهد، + // پس نسخهٔ بصری و schema واگرا نمی‌شوند. + ...(parent ? [{ name: parent.name, href: `/specialties/${parent.slug}` }] : []), // href صفحهٔ جاری: بدون آن `item` غایب می‌شود و اسکیما نامعتبر است { name: specialtyName, href: slug ? `/specialties/${slug}` : "/specialties" }, ]} @@ -21,6 +29,8 @@ function SpecialtyDetailPage({ specialtyName, cityName, doctors, intro, faq, ori متخصص {specialtyName} در {cityName} </h1> + <SpecialtyTree specialty={specialty} /> + <p className="text-[#525252] text-[14px] md:text-[16px] font-normal leading-8 mt-[16px] text-justify"> {intro} </p> 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 + ); +}