feat: Revert homepage changes, fix Persian slug 404, and make various article and about us adjustments

- Removed the newly added city-focused section from the homepage.
- Fixed 404 error for Persian slugs in specialties and blog pages by implementing a decode helper.
- Removed the "مخصوص شهر" label from article headers.
- Linked article tags to their respective filter pages.
- Adjusted related content images to prevent distortion.
- Added a section in the "About Us" page for physician registration guidance.
- Updated the logo in the "About Us" header to the correct version.
- Added tests for the new functionality and ensured existing tests are updated accordingly.
This commit is contained in:
hamed
2026-08-08 21:06:48 +03:30
parent e8a073cae6
commit 9cfbd2e002
18 changed files with 704 additions and 145 deletions
+1 -67
View File
@@ -1,71 +1,5 @@
import { describe, expect, it } from "vitest";
import cityData from "@/data/city.json";
import {
buildCityIntro,
buildClinicsIntro,
} from "@/lib/listingIntro";
const CITIES = ["بهبهان", "یاسوج", "یزد", "تبریز", "اصفهان"];
describe("buildCityIntro", () => {
it("دو شهر متن یکسان نمی‌گیرند", () => {
const a = buildCityIntro("بهبهان", 12, "جراحی عمومی");
const b = buildCityIntro("یاسوج", 80, "جراحی عمومی");
expect(a).not.toBe(b);
});
it("یک شهر در دو فراخوانی همان متن را می‌دهد", () => {
// ناپایداری یعنی محتوای صفحهٔ اصلی با هر rebuild عوض شود.
expect(buildCityIntro("بهبهان", 12, "جراحی عمومی")).toBe(
buildCityIntro("بهبهان", 12, "جراحی عمومی")
);
});
it("نام شهر و عدد و تخصص در متن می‌آیند", () => {
const text = buildCityIntro("بهبهان", 12, "جراحی عمومی");
expect(text).toContain("بهبهان");
expect(text).toContain("12");
expect(text).toContain("جراحی عمومی");
});
it("شمار صفر جمله را نمی‌شکند", () => {
const text = buildCityIntro("شهر تازه", 0, "");
expect(text).toContain("شهر تازه");
expect(text).not.toContain("undefined");
expect(text).not.toContain("NaN");
});
it("ورودی نامعتبر به صفر و تخصص پیش‌فرض می‌افتد", () => {
const text = buildCityIntro("شهر", "abc", null);
expect(text).not.toContain("NaN");
expect(text).toContain("پزشکی عمومی");
});
it("متن‌ها روی شهرهای واقعی به یک قالب واحد نمی‌چسبند", () => {
// اگر همهٔ شهرها یک variant بگیرند، دوباره همان duplicate ساخته می‌شود.
const texts = CITIES.map((c) => buildCityIntro(c, 10, "قلب و عروق"));
expect(new Set(texts).size).toBeGreaterThan(1);
});
it("متن به قدر کافی بلند هست که سیگنال محتوایی باشد", () => {
for (const city of CITIES) {
expect(buildCityIntro(city, 10, "قلب").length).toBeGreaterThan(200);
}
});
it("روی همهٔ شهرهای واقعی city.json بدون خطا اجرا می‌شود", () => {
for (const city of cityData) {
const text = buildCityIntro(city.name, 5, "داخلی");
expect(typeof text).toBe("string");
expect(text).not.toContain("undefined");
}
});
});
import { buildClinicsIntro } from "@/lib/listingIntro";
describe("جملهٔ آماری متن صفحات لیست", () => {
it("دو شهرِ هم‌variant با آمار متفاوت، متن یکسان نمی‌گیرند", () => {
+2 -35
View File
@@ -1,4 +1,4 @@
// متن مقدمهٔ یکتای صفحات لیست (/doctors و /clinics).
// متن مقدمهٔ یکتای صفحهٔ لیست /clinics.
// canonical درست، محتوای یکسان را غیرتکراری نمی‌کند — این متن همان نقش مکمل را دارد.
// مثل صفحات تخصص، ساختار جمله‌بندی هر شهر با هش نام شهر انتخاب می‌شود تا ۳۵ دامنه
// ۳۵ متن با ساختار متفاوت داشته باشند، نه یک قالب با نام جای‌گذاری‌شده.
@@ -21,38 +21,6 @@ const CLINIC_INTROS = (city) => [
`مرکز درمانی مناسب در ${city} را از این فهرست پیدا کنید. هر مرکز صفحهٔ اختصاصی دارد و آنچه آنجا می‌بینید از رکورد خودِ مرکز می‌آید: خدمات، تخصص‌ها، بیمه‌های طرف قرارداد و پزشکانی که پذیرش دارند. موقعیت روی نقشه هم هست تا پیش از مراجعه مسیر و زمان رسیدن را بدانید. نوبت‌گیری از صفحهٔ مرکز و از طریق پزشک انجام می‌شود؛ هزینهٔ خدمت پیش از تأیید نمایش داده می‌شود. لغو نوبت از حساب کاربری ممکن است و بازهٔ لغوشده همان لحظه به فهرست نوبت‌های آزاد برمی‌گردد. برای محدود کردن فهرست می‌توانید از فیلتر تخصص استفاده کنید تا فقط مراکزی بمانند که خدمت موردنظرتان را ارائه می‌دهند. نشانی و شمارهٔ تماس هر مرکز دوره‌ای بازبینی می‌شود.`,
];
// مقدمهٔ صفحهٔ اصلی هر دامنهٔ شهری.
//
// صفحهٔ اصلی تا پیش از این فقط `site_name` و `slogan` را از city.json شهری می‌کرد و
// بقیهٔ صفحه — بنر، کادر جستجو، جستجوهای پرتکرار، فوتر — روی همهٔ دامنه‌ها یکسان بود.
// اندازه‌گیری: شباهت متن رندرشدهٔ بهبهان و یاسوج ۹۹٫۰٪. گوگل canonicalِ درستِ ما را رد
// می‌کرد و صفحهٔ اصلی شهر را با دامنهٔ دیگری تجمیع می‌کرد
// (Duplicate, Google chose different canonical than user).
//
// عدد پزشک و تخصص داخل متن می‌آید تا تمایز فقط لفظی نباشد؛ هر شهر آمار خودش را دارد.
const CITY_INTROS = (city, doctorCount, topSpecialty) => [
`${doctorCount} پزشک از ${city} روی این سامانه پروفایل دارند. بیشترین‌شان ${topSpecialty}‌اند. نام پزشک یا نام تخصص را در کادر بالا بزنید؛ هر دو جست‌وجو می‌شوند. اگر هنوز نمی‌دانید سراغ کدام تخصص بروید، فهرست تخصص‌های همین شهر پایین‌تر آمده و از هر کدام مستقیم به پزشکانش می‌رسید.`,
`نوبت پزشکان ${city} را از همین‌جا می‌گیرید، در هر ساعت شبانه‌روز و بدون تماس با مطب. الان ${doctorCount} پزشک ثبت شده‌اند و ${topSpecialty} پرمراجعه‌ترین حوزه است. روی پروفایل هر پزشک، محل پذیرش و بازه‌های خالی همان هفته را می‌بینید. انتخاب کنید و تأیید بگیرید؛ پیامک تأییدیه همان لحظه می‌آید.`,
`اینجا نقطهٔ شروع برای پیدا کردن پزشک در ${city} است. ${doctorCount} پروفایل ثبت شده و ${topSpecialty} بیشترین سهم را دارد. هر پروفایل تخصص، نشانی محل پذیرش و امتیاز بیمارانی که قبلاً از همین‌جا نوبت گرفته‌اند را نشان می‌دهد. رزرو، تغییر و لغو نوبت همه از حساب کاربری خودتان انجام می‌شود.`,
`${city} روی این سامانه ${doctorCount} پزشک دارد و ${topSpecialty} از بقیه پرمراجعه‌تر است. کادر جست‌وجو نام پزشک و نام تخصص را با هم می‌گیرد. اگر ترجیح می‌دهید اول مرکز درمانی را انتخاب کنید، فهرست کلینیک‌ها جداست و پزشکان هر مرکز را کنار هم نشان می‌دهد.`,
`دنبال پزشک در ${city} می‌گردید؟ ${doctorCount} پزشک ثبت شده‌اند، بیشترشان در حوزهٔ ${topSpecialty}. نتیجهٔ جست‌وجو نشانی مطب، ساعات کاری و اولین نوبت آزاد هر پزشک را نشان می‌دهد. پزشکی که فعلاً نوبت‌دهی‌اش خاموش است هم می‌ماند، فقط با برچسب مشخص، تا بدانید گزینه هست.`,
`فهرست پزشکان ${city} الان ${doctorCount} پروفایل دارد و ${topSpecialty} صدرنشین مراجعه‌هاست. اطلاعات هر پزشک — تخصص، محل پذیرش، ساعت کاری — دوره‌ای بازبینی می‌شود تا با وضعیت واقعی مطب بخواند. نوبت با تأیید شما قطعی می‌شود و اگر برنامه‌تان عوض شد، لغوش از همان حساب ممکن است.`,
];
/**
* مقدمهٔ یکتای صفحهٔ اصلی یک شهر.
*
* seed از نام شهر می‌آید، نه تصادفی: متن باید بین شهرها واگرا شود ولی برای یک شهر
* در هر rebuild ثابت بماند، وگرنه محتوای صفحهٔ اصلی مدام عوض می‌شد.
*/
export function buildCityIntro(cityName, doctorCount = 0, topSpecialty = "") {
const count = Number(doctorCount) > 0 ? Number(doctorCount) : 0;
const specialty = topSpecialty || "پزشکی عمومی";
return pick(CITY_INTROS(cityName, count, specialty), hash(cityName) >>> 5);
}
/**
* جملهٔ آماری که به انتهای متن می‌چسبد.
*
@@ -60,8 +28,7 @@ export function buildCityIntro(cityName, doctorCount = 0, topSpecialty = "") {
* می‌شوند. برای آن جفت‌ها متن تقریباً یکسان می‌شد و دو صفحه دوباره شبیه هم — سنجیده
* شد: بهبهان و یاسوج روی /clinics از ۶۸٫۹٪ به ۸۴٫۷٪ شباهت رفتند.
*
* عدد واقعیِ هر شهر این را می‌شکند، چون هیچ دو شهری آمار یکسان ندارند. همان الگویی
* که در متن صفحهٔ اصلی جواب داد.
* عدد واقعیِ هر شهر این را می‌شکند، چون هیچ دو شهری آمار یکسان ندارند.
*/
const countSentence = (count, cityName, noun) =>
Number(count) > 0
+21
View File
@@ -0,0 +1,21 @@
/**
* segment مسیر را به شکل decode‌شده برمی‌گرداند.
*
* Next در `generateMetadata` مقدار decode‌شده می‌دهد ولی در بدنهٔ همان صفحه مقدار
* خام؛ روی اسلاگ‌های فارسی این یعنی متادیتا درست ساخته می‌شود و بلافاصله بعدش
* صفحه ۴۰۴ می‌دهد. با build واقعی سنجیده شد:
*
* [meta] "جراح-گوارش" found: true
* [body] "%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF..." found: false
*
* decode شکست‌خورده (درصدِ ناقص در ورودی) نباید صفحه را بشکند؛ همان مقدار خام
* برمی‌گردد تا نتیجه‌اش ۴۰۴ طبیعی باشد نه ۵۰۰.
*/
export function decodeRouteParam(value) {
if (typeof value !== "string" || !value.includes("%")) return value;
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { decodeRouteParam } from "@/lib/routeParams";
describe("decodeRouteParam", () => {
it("اسلاگ درصدی را به فارسی برمی‌گرداند", () => {
expect(
decodeRouteParam("%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4")
).toBe("جراح-گوارش");
});
it("فارسی خام دست‌نخورده می‌ماند", () => {
expect(decodeRouteParam("جراح-گوارش")).toBe("جراح-گوارش");
});
it("اسلاگ لاتین دست‌نخورده می‌ماند", () => {
expect(decodeRouteParam("gastroenterology")).toBe("gastroenterology");
});
it("درصد ناقص throw نمی‌کند و ورودی را برمی‌گرداند", () => {
// نتیجه‌اش باید ۴۰۴ طبیعی باشد، نه ۵۰۰.
expect(decodeRouteParam("%D8%")).toBe("%D8%");
expect(decodeRouteParam("100%-natural")).toBe("100%-natural");
});
it("ورودی غیر رشته‌ای همان‌طور برمی‌گردد", () => {
expect(decodeRouteParam(undefined)).toBeUndefined();
expect(decodeRouteParam(null)).toBeNull();
});
});