feat: رفع لینک /doctor/undefined و متمایزسازی صفحهٔ اصلی شهرها
- ایجاد پرامپت برای رفع خطای 404 ناشی از لینک `/doctor/undefined` در سایتهای بهبهان و یاسوج. - افزودن کامپوننت `CityHighlights` برای نمایش محتوای یکتا و آمار پزشکان و تخصصهای پرمراجعه در صفحهٔ اصلی هر شهر. - بهروزرسانی تستهای مربوط به کامپوننتهای `ItemDoctor` و `CityHighlights` برای اطمینان از عدم وجود لینکهای نامعتبر و نمایش صحیح اطلاعات. - ایجاد سند جدید برای مستندسازی خطاهای Search Console که باگ نیستند و نیاز به رفع ندارند. - افزودن تستهای واحد برای توابع `buildCityIntro` و دیگر توابع مرتبط با تولید متن یکتا برای صفحات لیست.
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user