feat: Enhance specialty handling and navigation across doctor and specialty pages
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -135,6 +135,7 @@ async function SpecialtyDetail({ params }) {
|
||||
/>
|
||||
<Layout name="/specialties">
|
||||
<SpecialtyDetailPage
|
||||
specialty={specialty}
|
||||
specialtyName={specialty.name}
|
||||
cityName={cityName}
|
||||
doctors={doctors}
|
||||
|
||||
@@ -5,6 +5,7 @@ import GreenCalendarTurn from "@/components/icons/GreenCalendarTurn";
|
||||
import { Button } from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import DoctorAvatar from "@/app/component/DoctorAvatar";
|
||||
import SpecialtyChips from "@/app/component/SpecialtyChips";
|
||||
import { bookingState } from "./bookingState";
|
||||
|
||||
function ItemAppointment({ turn, loading, doctorSlug, booking }) {
|
||||
@@ -26,13 +27,9 @@ function ItemAppointment({ turn, loading, doctorSlug, booking }) {
|
||||
<p className="text-[#3B3B3B] text-[14px] font-bold">{turn?.name}</p>
|
||||
</TextLoading>
|
||||
<TextLoading width={65} height={15} loading={loading}>
|
||||
<p className="text-[#525252] text-[14px] font-medium">
|
||||
تخصص:
|
||||
{turn?.specialties?.map(
|
||||
(item, idx) =>
|
||||
`${item.name} ${turn.specialties.length === idx + 1 ? "" : "|"} `
|
||||
)}
|
||||
</p>
|
||||
{/* ستون نوبتدهی باریک است؛ همان الگوی کارت پزشک — تخصص اصلی و
|
||||
شمارندهٔ بقیه — تا شش نام پشتسرهم ارتفاعش را چند برابر نکند. */}
|
||||
<SpecialtyChips specialties={turn?.specialties} />
|
||||
</TextLoading>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,12 +46,20 @@ function Title({ doctor }) {
|
||||
</div>
|
||||
</TextLoading>
|
||||
<TextLoading width={100} height={20}>
|
||||
<h2 className="text-[#525252] text-[14px] md:text-[16px] font-medium">
|
||||
تخصص:
|
||||
{doctor?.specialties?.map(
|
||||
(item, idx) =>
|
||||
`${item.name} ${doctor.specialties.length === idx + 1 ? "" : "|"} `
|
||||
)}
|
||||
{/* اینجا جای فهرست کاملِ تخصصهاست — سربرگ صفحه فقط تخصص اصلی را نشان
|
||||
میدهد. چیپهای شکننده جای رشتهٔ درهم نشستند: پیش از این خروجی
|
||||
«تخصص:جراحی عمومی | … | » بود، بدون فاصله بعد از دونقطه و با
|
||||
جداکنندهٔ آویزان در انتها. */}
|
||||
<h2 className="flex flex-wrap items-center gap-[6px] text-[#525252] text-[14px] md:text-[16px] font-medium">
|
||||
<span>تخصص:</span>
|
||||
{doctor?.specialties?.map((item) => (
|
||||
<span
|
||||
key={item.id ?? item.name}
|
||||
className="px-[10px] py-[3px] rounded-md bg-[#F8F8FF] text-[13px] md:text-[14px] font-normal"
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
))}
|
||||
</h2>
|
||||
</TextLoading>
|
||||
<div className="flex items-center justify-start gap-11">
|
||||
|
||||
@@ -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(<DoctorPage doctor={JAHANTAB} />);
|
||||
|
||||
expect(screen.getByRole("link", { name: "جراحی عمومی" })).toBeInTheDocument();
|
||||
// بقیه اینجا نمیآیند؛ فهرست کامل در بدنهٔ صفحه است و آنجا میشکند.
|
||||
expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("جراحی سرطانها")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("تخصص اصلی به صفحهٔ فرود همان تخصص لینک میدهد", () => {
|
||||
render(<DoctorPage doctor={JAHANTAB} />);
|
||||
|
||||
expect(screen.getByRole("link", { name: "جراحی عمومی" })).toHaveAttribute(
|
||||
"href",
|
||||
"/specialties/general-surgery"
|
||||
);
|
||||
});
|
||||
|
||||
it("نام پزشک با عنوان نمایش داده میشود", () => {
|
||||
render(<DoctorPage doctor={JAHANTAB} />);
|
||||
|
||||
expect(screen.getByText(/محمدباقر جهانتاب/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("پزشکِ فقط-زیرتخصص، همان زیرتخصص را در سربرگ میگیرد", () => {
|
||||
render(
|
||||
<DoctorPage
|
||||
doctor={{ ...JAHANTAB, specialties: [{ id: "169", name: "جراح گوارش", parent_id: "13" }] }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: "جراح گوارش" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("پزشک بدون تخصص سربرگ خالی میگیرد و کرش نمیکند", () => {
|
||||
expect(() =>
|
||||
render(<DoctorPage doctor={{ ...JAHANTAB, specialties: [] }} />)
|
||||
).not.toThrow();
|
||||
|
||||
expect(screen.getByText(/محمدباقر جهانتاب/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+17
-12
@@ -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 (
|
||||
<div className="pt-[92px] sm:pt-[120px] mt:pt-[148px] lg:pt-[176px] mt-[px] padding-responsive">
|
||||
<div className="flex items-center justify-between">
|
||||
<CustomLoading width={180} height={25}>
|
||||
<div className="flex items-center justify-start text-[14px] md:text-[16px] font-normal">
|
||||
<h2 className="text-[#9B9B9B] text-nowrap flex items-center">
|
||||
{doctor?.specialties?.map((item, idx) => (
|
||||
<span key={item.id ?? item.name} className="flex items-center">
|
||||
<Link href={specialtyHref(item.name)} className="hover:text-[#525252]">
|
||||
{item.name}
|
||||
</Link>
|
||||
<span className="mx-1">
|
||||
{doctor.specialties.length === idx + 1 ? ">" : "|"}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</h2>
|
||||
{/* این خط یک breadcrumb است، نه فهرست تخصص. چاپ هر شش تخصص با
|
||||
text-nowrap در موبایل از عرض صفحه بیرون میزد. فهرست کامل چند خط
|
||||
پایینتر در Title.js میآید و آنجا میشکند. */}
|
||||
{primarySpecialty && (
|
||||
<h2 className="text-[#9B9B9B] text-nowrap flex items-center min-w-0">
|
||||
<Link
|
||||
href={specialtyHref(primarySpecialty.name)}
|
||||
className="hover:text-[#525252] truncate"
|
||||
>
|
||||
{primarySpecialty.name}
|
||||
</Link>
|
||||
<span className="mx-1">{">"}</span>
|
||||
</h2>
|
||||
)}
|
||||
<p className="text-[#525252] text-nowrap mr-1">{doctorTitle(doctor?.name)}</p>
|
||||
</div>
|
||||
</CustomLoading>
|
||||
|
||||
@@ -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(<Title doctor={DOCTOR} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user