feat: Enhance specialty handling and navigation across doctor and specialty pages

This commit is contained in:
hamed
2026-08-08 17:33:13 +03:30
parent 56e264e44c
commit 79747c443c
12 changed files with 435 additions and 27 deletions
@@ -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;
@@ -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();
});
});
@@ -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,
]);
});
});
+11 -1
View File
@@ -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>