feat: رفع لینک /doctor/undefined و متمایزسازی صفحهٔ اصلی شهرها

- ایجاد پرامپت برای رفع خطای 404 ناشی از لینک `/doctor/undefined` در سایت‌های بهبهان و یاسوج.
- افزودن کامپوننت `CityHighlights` برای نمایش محتوای یکتا و آمار پزشکان و تخصص‌های پرمراجعه در صفحهٔ اصلی هر شهر.
- به‌روزرسانی تست‌های مربوط به کامپوننت‌های `ItemDoctor` و `CityHighlights` برای اطمینان از عدم وجود لینک‌های نامعتبر و نمایش صحیح اطلاعات.
- ایجاد سند جدید برای مستندسازی خطاهای Search Console که باگ نیستند و نیاز به رفع ندارند.
- افزودن تست‌های واحد برای توابع `buildCityIntro` و دیگر توابع مرتبط با تولید متن یکتا برای صفحات لیست.
This commit is contained in:
hamed
2026-08-08 19:14:50 +03:30
parent 79747c443c
commit b494473e40
13 changed files with 1047 additions and 28 deletions
+7
View File
@@ -6,6 +6,8 @@ import { getStateInfo } from "@/lib/getStateInfo";
import { buildClinicParams } from "@/helper";
import { listingRobots } from "@/lib/listingRobots";
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
import { buildClinicsIntro } from "@/lib/listingIntro";
import { resolveCityDisplayName } from "@/lib/domainHelpers";
export async function generateMetadata({ searchParams }) {
const awaitedParams = await searchParams;
@@ -79,6 +81,11 @@ export default async function Clinics({ searchParams }) {
matchedCity={matchedCity}
matchedState={matchedState}
origin={origin}
// قرینهٔ /doctors — buildClinicsIntro هم نوشته و تست شده بود ولی رندر نمی‌شد.
intro={buildClinicsIntro(
resolveCityDisplayName(isRoot ? null : matchedCity),
clinics?.meta?.totalRecords
)}
/>
</Layout>
);
+30 -22
View File
@@ -14,6 +14,12 @@ import LikeD from "@/components/icons/LikeD";
import PointD from "@/components/icons/PointD";
function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
// بدون uuid لینکی وجود ندارد. اسکلتِ بارگذاری آبجکتِ بی‌uuid می‌دهد و پیش از این
// شش `<a href="/doctor/undefined">` در HTML می‌نشست؛ Googlebot همان را crawl می‌کرد
// و ۴۰۴ می‌گرفت (گزارش Search Console، ۳۰ ژوئیه).
const href = doctor?.uuid ? `/doctor/${doctor.uuid}` : null;
const label = doctor?.display_name || doctor?.name;
const handleLoading = () => {
setDoctors((prevDoctors) =>
prevDoctors.map((item) =>
@@ -38,11 +44,13 @@ function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
</CircularLoading>
<div className="flex flex-col items-start justify-center gap-2">
<TextLoading loading={loading} width={90} height={15}>
<Link href={`/doctor/${doctor?.uuid}`}>
<p className="text-[#3B3B3B] text-[14px] font-bold">
{doctor?.display_name || doctor?.name}
</p>
</Link>
{href ? (
<Link href={href}>
<p className="text-[#3B3B3B] text-[14px] font-bold">{label}</p>
</Link>
) : (
<p className="text-[#3B3B3B] text-[14px] font-bold">{label}</p>
)}
</TextLoading>
<TextLoading loading={loading} width={120} height={15}>
<SpecialtyChips specialties={doctor?.specialties} />
@@ -84,23 +92,23 @@ function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
: "نوبت‌دهی غیرفعال است"}
</p>
</CustomLoading>
{/* Arrow */}
<Link
href={`/doctor/${doctor?.uuid}`}
aria-label={`مشاهده پروفایل ${doctor?.display_name || doctor?.name || "پزشک"}`}
>
<Button
variant="contained"
onClick={handleLoading}
disabled={doctor?.loading}
aria-label={`مشاهده پروفایل ${doctor?.display_name || doctor?.name || "پزشک"}`}
className="!p-[14px] !absolute !left-4 !bottom-4 !rounded-full !w-fit"
>
<div className={doctor && doctor.loading ? "opacity-0" : ""}>
<ArrowLeftD />
</div>
</Button>
</Link>
{/* Arrow — روی کارتِ بدون uuid (اسکلت بارگذاری) اصلاً رندر نمی‌شود؛ دکمه‌ای که
به جایی نمی‌رود، هم بی‌معناست هم لینکِ شکسته در HTML می‌گذارد. */}
{href && (
<Link href={href} aria-label={`مشاهده پروفایل ${label || "پزشک"}`}>
<Button
variant="contained"
onClick={handleLoading}
disabled={doctor?.loading}
aria-label={`مشاهده پروفایل ${label || "پزشک"}`}
className="!p-[14px] !absolute !left-4 !bottom-4 !rounded-full !w-fit"
>
<div className={doctor && doctor.loading ? "opacity-0" : ""}>
<ArrowLeftD />
</div>
</Button>
</Link>
)}
</li>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import ItemDoctor from "@/app/component/ItemDoctor";
import Loading from "@/app/doctors/loading";
const DOCTOR = {
id: 1,
uuid: "84b7ea9e-2fe1-4413-906d-7a5bf05e6f8f",
name: "محمدباقر جهانتاب",
specialties: [{ id: "13", name: "جراحی عمومی", parent_id: null }],
active: 1,
free_turn: "شنبه ۱۷:۴۵",
};
const noop = () => {};
describe("ItemDoctor — لینک پروفایل", () => {
it("پزشک با uuid لینک می‌گیرد", () => {
render(<ItemDoctor doctor={DOCTOR} setDoctors={noop} />);
const links = screen.getAllByRole("link");
expect(links.length).toBeGreaterThan(0);
for (const link of links) {
expect(link).toHaveAttribute("href", `/doctor/${DOCTOR.uuid}`);
}
});
it("کارت بدون uuid هیچ لینکی ندارد", () => {
render(<ItemDoctor doctor={{ id: 0 }} loading setDoctors={noop} />);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
it("رشتهٔ undefined هرگز در HTML نمی‌نشیند", () => {
// ریشهٔ خطای Not found (404) در Search Console: href="/doctor/undefined".
const { container } = render(
<ItemDoctor doctor={{ id: 0 }} loading setDoctors={noop} />
);
expect(container.innerHTML).not.toContain("undefined");
});
it("نام پزشک حتی بدون لینک نمایش داده می‌شود", () => {
render(<ItemDoctor doctor={{ id: 0, name: "بدون شناسه" }} setDoctors={noop} />);
expect(screen.getByText("بدون شناسه")).toBeInTheDocument();
});
it("display_name بر name مقدم است", () => {
render(
<ItemDoctor
doctor={{ ...DOCTOR, display_name: "دکتر جهانتاب" }}
setDoctors={noop}
/>
);
expect(screen.getByText("دکتر جهانتاب")).toBeInTheDocument();
});
});
describe("اسکلت بارگذاری /doctors", () => {
it("هیچ لینک doctor/undefined تولید نمی‌کند", () => {
const { container } = render(<Loading />);
expect(container.innerHTML).not.toContain("/doctor/undefined");
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
it("همچنان شش کارت اسکلت می‌سازد", () => {
const { container } = render(<Loading />);
expect(container.querySelectorAll("li")).toHaveLength(6);
});
});
+9
View File
@@ -5,6 +5,8 @@ import { getStateInfo } from "@/lib/getStateInfo";
import { fetchReq } from "@/lib/req";
import { isNextRedirectError } from "@/lib/maintenance";
import { listingRobots } from "@/lib/listingRobots";
import { buildDoctorsIntro } from "@/lib/listingIntro";
import { resolveCityDisplayName } from "@/lib/domainHelpers";
export async function generateMetadata({ searchParams }) {
const awaitedParams = await searchParams;
@@ -75,6 +77,13 @@ async function Doctors({ searchParams }) {
params={searchParams}
matchedCity={matchedCity}
matchedState={matchedState}
// متن مقدمه سمت سرور ساخته می‌شود تا در HTML اولیه باشد و خزنده ببیندش.
// buildDoctorsIntro از قبل نوشته و تست شده بود اما هیچ‌جا رندر نمی‌شد، پس
// این صفحه روی هر ۳۵ دامنه محتوای یکسان داشت.
intro={buildDoctorsIntro(
resolveCityDisplayName(isRoot ? null : matchedCity),
doctors?.meta?.totalRecords
)}
/>
</Layout>
);