feat: Enhance specialty handling and navigation across doctor and specialty pages
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user