- ایجاد پرامپت برای رفع خطای 404 ناشی از لینک `/doctor/undefined` در سایتهای بهبهان و یاسوج. - افزودن کامپوننت `CityHighlights` برای نمایش محتوای یکتا و آمار پزشکان و تخصصهای پرمراجعه در صفحهٔ اصلی هر شهر. - بهروزرسانی تستهای مربوط به کامپوننتهای `ItemDoctor` و `CityHighlights` برای اطمینان از عدم وجود لینکهای نامعتبر و نمایش صحیح اطلاعات. - ایجاد سند جدید برای مستندسازی خطاهای Search Console که باگ نیستند و نیاز به رفع ندارند. - افزودن تستهای واحد برای توابع `buildCityIntro` و دیگر توابع مرتبط با تولید متن یکتا برای صفحات لیست.
139 lines
5.6 KiB
JavaScript
139 lines
5.6 KiB
JavaScript
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;
|