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
+138
View File
@@ -0,0 +1,138 @@
import Link from "next/link";
import { getStateInfo } from "@/lib/getStateInfo";
import { fetchReq } from "@/lib/req";
import { buildCityIntro } from "@/lib/listingIntro";
import { isNoindexDoctor } from "@/lib/entityQuality";
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const SHOWN_SPECIALTIES = 8;
const SHOWN_DOCTORS = 4;
/**
* بخش شهرمحور صفحهٔ اصلی — متن یکتا، آمار واقعی و تخصص‌های پرمراجعهٔ همان شهر.
*
* چرا لازم است: صفحهٔ اصلی تا پیش از این فقط `site_name` و `slogan` را شهری می‌کرد و
* متن رندرشدهٔ دو دامنه ۹۹٫۰٪ یکسان بود. گوگل canonicalِ درستِ ما را رد می‌کرد و
* صفحهٔ اصلی شهر را با دامنهٔ دیگر تجمیع می‌کرد. تمایز فقط با تگ حل نمی‌شود؛ محتوا
* باید واقعاً فرق کند، و آمارِ هر شهر خودبه‌خود فرق دارد.
*
* جدا از FrequentSearches است: آن فهرست ثابتِ تخصص‌هاست و روی همهٔ دامنه‌ها یکی؛ این
* یکی از داده‌ی همان شهر ساخته می‌شود.
*/
async function CityHighlights() {
const { matchedCity, isRoot } = await getStateInfo();
// دامنهٔ ریشه شهر نیست؛ آمار و متن شهری آنجا معنا ندارد.
if (isRoot || !matchedCity?.id) return null;
const [counts, doctors] = await Promise.all([
fetchCityCounts(matchedCity.id),
fetchCityDoctors(matchedCity.id),
]);
if (counts.length === 0) return null;
const doctorCount = counts.reduce((sum, s) => sum + s.number_of_doctors, 0);
if (doctorCount === 0) return null;
const top = counts.slice(0, SHOWN_SPECIALTIES);
const cityName = matchedCity.name;
return (
<section className="w-full max-w-[900px] z-[50]">
<p className="text-[#525252] text-[13px] md:text-[15px] font-normal leading-8 text-justify">
{buildCityIntro(cityName, doctorCount, top[0].name)}
</p>
<p className="mt-[20px] text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
تخصصهای پرمراجعه در {cityName}
</p>
<ul className="mt-[12px] flex flex-wrap items-center gap-[8px]">
{top.map((specialty) => (
<li key={specialty.id}>
<Link
href={`/specialties/${specialty.slug}`}
className="flex items-center gap-1.5 py-[6px] px-[12px] rounded-[4px] bg-[#FFF] border border-[#D7D7D7] text-[12px] lg:text-[14px] font-normal text-[#3B3B3B] hover:bg-[#EFEFEF] transition-all duration-500"
>
<span>{specialty.name}</span>
<span className="text-[#7E7E7E]">{specialty.number_of_doctors}</span>
</Link>
</li>
))}
</ul>
{doctors.length > 0 && (
<>
<p className="mt-[20px] text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
پزشکان {cityName}
</p>
<ul className="mt-[12px] flex flex-wrap items-center gap-[8px]">
{doctors.map((doctor) => (
<li key={doctor.uuid}>
<Link
href={`/doctor/${doctor.uuid}`}
className="flex items-center gap-1.5 py-[6px] px-[12px] rounded-[4px] bg-[#FFF] border border-[#D7D7D7] text-[12px] lg:text-[14px] font-normal text-[#3B3B3B] hover:bg-[#EFEFEF] transition-all duration-500"
>
<span>{doctor.display_name || doctor.name}</span>
{doctor.specialty && (
<span className="text-[#7E7E7E]">{doctor.specialty}</span>
)}
</Link>
</li>
))}
</ul>
</>
)}
</section>
);
}
/**
* تخصص‌های همان شهر که پزشک دارند، از پرپزشک به کم‌پزشک.
*
* خطا اینجا بلعیده می‌شود و آرایهٔ خالی برمی‌گردد: صفحهٔ اصلی مهم‌ترین صفحهٔ سایت است
* و قطعیِ API نباید سفیدش کند. این همان مرز واقعیِ error handling است — یک I/O boundary.
*/
/**
* چند پزشکِ همان شهر برای لینک مستقیم از صفحهٔ اصلی.
*
* بک‌اند خودش «دارای نوبت» را بالاتر مرتب می‌کند، پس ترتیب پیش‌فرض همان چیزی است که
* می‌خواهیم. پروفایل noindex کنار گذاشته می‌شود — همان سیاستی که sitemap اعمال
* می‌کند؛ لینک دادن از صفحهٔ اصلی به صفحه‌ای که خودمان از ایندکس بیرونش گذاشته‌ایم،
* سیگنال متناقض است.
*/
async function fetchCityDoctors(cityId) {
const data = await fetchReq(
`${API_URL}/api/v1/doctors?city_id=${cityId}&limit=12`
);
const items = data?.data ?? [];
if (!Array.isArray(items)) return [];
return items
.filter((d) => !isNoindexDoctor(d))
.slice(0, SHOWN_DOCTORS)
.map((d) => ({
uuid: d.uuid,
name: d.name,
display_name: d.display_name,
specialty: d.specialties?.[0]?.name ?? null,
}));
}
async function fetchCityCounts(cityId) {
const data = await fetchReq(
`${API_URL}/api/v1/specialties/doctor-counts?city_id=${cityId}`
);
const items = data?.data?.data ?? [];
if (!Array.isArray(items)) return [];
return items
.map((s) => ({ ...s, number_of_doctors: Number(s?.number_of_doctors) || 0 }))
.filter((s) => s.slug && s.number_of_doctors > 0)
.sort((a, b) => b.number_of_doctors - a.number_of_doctors);
}
export default CityHighlights;