From 9cfbd2e0026003320ff3c2f1814a6f197d2c81c6 Mon Sep 17 00:00:00 2001
From: hamed <15238-genius.ha@users.noreply.drupalcode.org>
Date: Sat, 8 Aug 2026 21:06:48 +0330
Subject: [PATCH] feat: Revert homepage changes, fix Persian slug 404, and make
various article and about us adjustments
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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.
---
...page-revert-persian-slug-404-blog-fixes.md | 469 ++++++++++++++++++
app/blog/[slug]/page.js | 18 +-
app/specialties/[slug]/page.js | 7 +-
components/aboutUs/Head.js | 12 +-
components/aboutUs/aboutUs.test.js | 46 ++
components/aboutUs/index.js | 18 +-
components/blog/head/blogHead.test.js | 55 ++
components/blog/head/index.js | 29 +-
components/blog/index.js | 4 +-
components/blog/relatedContent/Item.js | 4 +-
.../blog/relatedContent/relatedItem.test.js | 26 +
components/doctor/poster/PosterLight.js | 2 +-
components/doctor/poster/index.js | 2 +-
components/home/index.js | 2 -
lib/cityIntro.test.js | 68 +--
lib/listingIntro.js | 37 +-
lib/routeParams.js | 21 +
lib/routeParams.test.js | 29 ++
18 files changed, 704 insertions(+), 145 deletions(-)
create mode 100644 .claude/prompt/homepage-revert-persian-slug-404-blog-fixes.md
create mode 100644 components/aboutUs/aboutUs.test.js
create mode 100644 components/blog/head/blogHead.test.js
create mode 100644 components/blog/relatedContent/relatedItem.test.js
create mode 100644 lib/routeParams.js
create mode 100644 lib/routeParams.test.js
diff --git a/.claude/prompt/homepage-revert-persian-slug-404-blog-fixes.md b/.claude/prompt/homepage-revert-persian-slug-404-blog-fixes.md
new file mode 100644
index 0000000..e99032a
--- /dev/null
+++ b/.claude/prompt/homepage-revert-persian-slug-404-blog-fixes.md
@@ -0,0 +1,469 @@
+# بازگرداندن صفحهٔ اصلی، رفع ۴۰۴ اسلاگ فارسی، سه اصلاح صفحهٔ مقاله، و دو اصلاح «درباره ما»
+
+## پروژه
+
+`nobat724_front` — سایت عمومی. بدون تغییر بکاند.
+
+## زمینه
+
+پنج مورد جدا که کاربر روی محیط محلی دید. یکی بازگرداندن تغییری است که در تسک قبلی
+اضافه شد، یکی باگ ۴۰۴ با ریشهٔ اثباتشده، و سه اصلاح کوچک روی صفحهٔ مقاله.
+
+## مشکل / هدف
+
+### ۱. بخش تازهٔ صفحهٔ اصلی برداشته شود
+
+در تسک قبلی برای رفع `Duplicate, Google chose different canonical` یک بخش شهرمحور
+به صفحهٔ اصلی اضافه شد: متن مقدمه، «تخصصهای پرمراجعه در …» و «پزشکان …».
+کاربر آن را نمیخواهد. صفحهٔ اصلی به حالت قبل برگردد.
+
+### ۲. اسلاگ فارسی ۴۰۴ میدهد
+
+```
+/specialties/%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4 → 404
+/specialties/gastroenterology → 200
+```
+
+**ریشه با ابزارگذاری روی build واقعی اثبات شد** — `generateMetadata` و بدنهٔ صفحه دو
+مقدار متفاوت میگیرند:
+
+```
+generateMetadata → slug = "جراح-گوارش" found: true
+بدنهٔ صفحه → slug = "%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4" found: false → notFound()
+```
+
+یعنی Next در بدنهٔ صفحه segment را decode نمیکند. رشتهها بایتبهبایت یکیاند
+(هر دو NFC، بدون تفاوت codepoint)، پس مشکل نرمالسازی نیست.
+
+**این باگ فقط مخصوص صفحهٔ تخصص نیست.** slug مقالهها هم فارسی است
+(`آلرژی-دارویی-…-58d2e880`)، پس `app/blog/[slug]/page.js` هم همین را دارد و امروز
+با uuid تست میشود که ASCII است و مشکل را پنهان میکند.
+
+### ۳. برچسب «مخصوص شهر» از صفحهٔ مقاله حذف شود
+
+فقط همین جمله. منطق scope شهریِ مقالهها دستنخورده میماند.
+
+### ۴. تگهای مقاله لینک شوند
+
+الان چیپهای بدون لینکاند. کامنت کد میگوید «صفحهٔ فیلتر تگ وجود ندارد» — این کامنت
+کهنه است؛ `components/blogs/index.js` از قبل `?tag=` را میخواند و اعمال میکند.
+
+### ۵. عکس «مطالب مرتبط» دفرمه است
+
+عکس داخل `
` است و ظرفش `shrink-0` ندارد. با تیتر بلند، عرض عکس
+فشرده میشود و ارتفاعش ثابت میماند.
+
+### ۶. صفحهٔ «درباره ما» راه عضویت پزشک را نمیگوید
+
+مسیر صفحه:
+
+```
+/about-us
+```
+
+متن فعلی فقط از دید بیمار نوشته شده. پزشکی که بخواهد به سامانه اضافه شود هیچ
+راهنماییای نمیبیند. باید بند تازهای اضافه شود که او را به این سایت بفرستد:
+
+```
+https://clinic-pro.ir/
+```
+
+### ۷. لوگوی هدر «درباره ما» غلط است
+
+هدر بنفش صفحهٔ «درباره ما» فایل قدیمی را نشان میدهد:
+
+```
+/assets/images/logo-2.png
+```
+
+هیچجای دیگر سایت این فایل را مصرف نمیکند. لوگوی واقعی سایت این است:
+
+```
+/nobat724.svg
+```
+
+که هدر اصلی سایت در `app/component/Logo.js` از آن استفاده میکند.
+
+**دو نکته که سادهترین راهحل را میشکنند:**
+
+اول، رنگ stroke لوگو `#5559CE` است و پسزمینهٔ هدر `#5559C2`. اگر همان svg را
+مستقیم بگذارید، لوگو تقریباً نامرئی میشود. باید نسخهٔ روشن رندر شود.
+
+دوم، `SiteLogo` از قبل همین کار را میکند: هم `city.logo_url` را در نظر میگیرد و
+هم `stroke` را پارامتری گرفته. مسیرش:
+
+```
+components/doctor/poster/SiteLogo.js
+```
+
+## معیار پذیرش
+
+- ✅ موفق: `/specialties/%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4`
+ کد `200` بدهد و تیتر «متخصص جراح گوارش در …» را نشان دهد.
+- ✅ موفق: `/specialties/gastroenterology` همچنان `200` — اسلاگ لاتین نشکند.
+- ✅ موفق: صفحهٔ مقاله دیگر «مخصوص شهر:» ندارد، ولی نویسنده و تاریخ سر جایشاناند.
+- ✅ موفق: هر تگ مقاله لینک به `/blogs?tag=<نام تگ>` است و آن صفحه همان فیلتر را اعمال میکند.
+- ✅ موفق: HTML صفحهٔ اصلی هیچکدام از «تخصصهای پرمراجعه در»، «پزشکان <شهر>» را ندارد.
+- ❌ خطا: اسلاگ ناموجود — چه فارسی چه لاتین — همچنان `404` بدهد، نه `200` یا `500`.
+- ❌ خطا: مقالهای بدون تگ، بلوک تگ را اصلاً رندر نکند.
+- ⚠️ مرزی: اسلاگ فارسی که با `%` شروع نمیشود ولی حرف فارسی خام دارد هم کار کند.
+- ⚠️ مرزی: تگ حاوی فاصله یا `&` باید در URL درست encode شود.
+- ⚠️ مرزی: عکس مطالب مرتبط با تیتر بسیار بلند نباید باریک شود؛ نسبت ابعاد ثابت بماند.
+- ✅ موفق: صفحهٔ `/about-us` بندی دارد که پزشک را به `https://clinic-pro.ir/` میفرستد،
+ و آن آدرس لینک واقعی است نه متن ساده.
+- ✅ موفق: لینک عضویت پزشک `target="_blank"` و `rel="noopener noreferrer"` دارد.
+- ✅ موفق: هیچ ارجاعی به `logo-2.png` در کد نماند و لوگوی هدر «درباره ما» روی
+ پسزمینهٔ بنفش دیده شود.
+- ❌ خطا: نام سایت در بند تازه هاردکد نشود؛ همان `siteName` که بقیهٔ صفحه استفاده میکند.
+- ⚠️ مرزی: شهری که `logo_url` دارد باید لوگوی خودش را در هدر ببیند، نه لوگوی پیشفرض.
+
+## فایلهای مرتبط
+
+| فایل | نقش |
+|------|-----|
+| `components/home/index.js` | چیدمان صفحهٔ اصلی — `CityHighlights` از اینجا حذف شود |
+| `components/home/CityHighlights.js` | کامپوننتی که باید برداشته شود |
+| `components/home/CityHighlights.test.js` | تستش هم با آن میرود |
+| `lib/listingIntro.js` | `buildCityIntro` و `CITY_INTROS` — فقط مصرفکنندهشان صفحهٔ اصلی بود |
+| `lib/cityIntro.test.js` | تستهای `buildCityIntro` |
+| `app/specialties/[slug]/page.js` | ۴۰۴ اسلاگ فارسی |
+| `app/blog/[slug]/page.js` | همان باگ، پنهان چون با uuid تست میشود |
+| `components/blog/head/index.js` | برچسب «مخصوص شهر» و چیپهای تگ |
+| `components/blogs/index.js` | خوانندهٔ `?tag=` — تأیید میکند لینک تگ کار میکند |
+| `components/blog/relatedContent/Item.js` | عکس دفرمه |
+| `components/aboutUs/index.js` | متن «درباره ما» — بند تازهٔ پزشک اینجا اضافه شود |
+| `components/aboutUs/Head.js` | هدر بنفش با لوگوی غلط |
+| `components/doctor/poster/SiteLogo.js` | لوگوی درست، `stroke` پارامتری، پشتیبانی `logo_url` |
+| `app/component/Logo.js` | لوگوی هدر اصلی — مرجع اینکه لوگوی واقعی کدام است |
+| `public/assets/images/logo-2.png` | فایل یتیم — بعد از اصلاح هیچ مصرفکنندهای ندارد |
+
+## وضعیت فعلی
+
+### صفحهٔ اصلی
+
+```jsx
+// components/home/index.js
+
+)}
+```
+
+### تگهای بدون لینک
+
+```jsx
+// components/blog/head/index.js:90
+{/* فهرست کامل تگها — تا این تغییر فقط تگ اول در بردکرامب دیده میشد.
+ صفحهٔ فیلتر تگ وجود ندارد، پس چیپها بدون لینک رندر میشوند. */}
+{tags.length > 0 && (
+
+```
+
+`Head` الان فقط `siteName` و `slogan` میگیرد؛ `matchedCity` به آن پاس نمیشود.
+
+### متن «درباره ما»
+
+```jsx
+// components/aboutUs/index.js — دو بند، هر دو از دید بیمار
+
{siteName} یک سامانهی آنلاین نوبتدهی پزشکی است که …
+
در {siteName} میتوانید بر اساس تخصص، نام پزشک یا مرکز درمانی جستجو کنید …
+```
+
+## وظایف
+
+### ۱. برداشتن بخش شهرمحور صفحهٔ اصلی
+
+- `` و importش از `components/home/index.js` حذف شود.
+- فایل `components/home/CityHighlights.js` و تستش پاک شوند.
+- `buildCityIntro` و `CITY_INTROS` در `lib/listingIntro.js` تنها مصرفکنندهشان همین
+ کامپوننت بود؛ با آن پاک شوند. تستهای مربوطه در `lib/cityIntro.test.js` هم بروند.
+ **کد مرده نگه ندارید** — همین اشتباه قبلاً با `buildDoctorsIntro` تکرار شد.
+- `buildClinicsIntro` و `CLINIC_INTROS` بمانند؛ `/clinics` هنوز مصرفشان میکند.
+
+**نحوه تست:**
+```bash
+grep -rn "CityHighlights\|buildCityIntro" app components lib # باید خالی باشد
+npm run test && npm run lint
+NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build && npm run start &
+curl -s -H "Host: yasuj-nobat.ir" http://localhost:3000/ | grep -cE "تخصصهای پرمراجعه|پزشکان یاسوج" # باید 0
+```
+
+### ۲. رفع ۴۰۴ اسلاگ فارسی
+
+یک helper مشترک بسازید — این باگ در دو صفحه هست و فردا در صفحهٔ سوم هم میآید:
+
+```js
+// lib/routeParams.js
+/**
+ * segment مسیر را به شکل decodeشده برمیگرداند.
+ *
+ * Next در `generateMetadata` مقدار decodeشده میدهد ولی در بدنهٔ همان صفحه مقدار
+ * خام؛ روی اسلاگهای فارسی این یعنی متادیتا درست ساخته میشود و بلافاصله بعدش
+ * صفحه ۴۰۴ میدهد. اثباتشده روی build واقعی، نه فرض.
+ *
+ * decode شکستخورده (درصدِ ناقص در ورودی) نباید صفحه را بشکند؛ همان مقدار خام
+ * برمیگردد تا نتیجهاش ۴۰۴ طبیعی باشد نه ۵۰۰.
+ */
+export function decodeRouteParam(value) {
+ if (typeof value !== "string" || !value.includes("%")) return value;
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+```
+
+سپس در هر دو صفحه، بلافاصله بعد از `await params`:
+
+```js
+const { slug: rawSlug } = await params;
+const slug = decodeRouteParam(rawSlug);
+```
+
+در `app/specialties/[slug]/page.js` هر دو جا (`generateMetadata` و بدنه) و در
+`app/blog/[slug]/page.js` هم هر دو جا.
+
+**مواظب `slug` که به کامپوننت پاس میشود باشید:** در صفحهٔ تخصص، `slug` به
+`SpecialtyDetailPage` میرود و آنجا در breadcrumb به `/specialties/${slug}` تبدیل
+میشود. مقدار decodeشده آنجا درست است چون `next/link` خودش encode میکند.
+
+**نحوه تست:**
+```bash
+NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build && npm run start &
+for u in "/specialties/%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4" \
+ "/specialties/gastroenterology" "/specialties/does-not-exist"; do
+ echo -n "$u → "; curl -s -o /dev/null -w "%{http_code}\n" -H "Host: yasuj-nobat.ir" "http://localhost:3000$u"
+done
+# انتظار: 200، 200، 404
+```
+بهعلاوه unit test برای `decodeRouteParam` در `lib/routeParams.test.js`: مقدار
+encodeشده، مقدار خام فارسی، مقدار لاتین، رشتهٔ با `%` ناقص (نباید throw کند)،
+ورودی `undefined`.
+
+### ۳. حذف برچسب «مخصوص شهر»
+
+کل بلوک `{cityName && (...)}` در `components/blog/head/index.js` برداشته شود.
+اگر `cityName` بعد از آن هیچ مصرفی ندارد، محاسبهاش هم پاک شود تا متغیر بلااستفاده نماند.
+
+منطق scope شهریِ مقالهها (`domainScopeCityId` و `city_id` در fetch) **دست نخورد** —
+کاربر فقط همین جمله را خواست.
+
+**نحوه تست:** `curl` صفحهٔ مقاله و `grep -c "مخصوص شهر"` که باید صفر باشد، در حالی که
+`grep -c "نویسنده:"` همچنان یک است.
+
+### ۴. لینککردن تگها
+
+هر چیپ به `/blogs?tag=<نام>` لینک شود. `next/link` خودش encode میکند، ولی چون تگ
+ممکن است فاصله یا `&` داشته باشد، از شکل شیئی استفاده کنید تا مطمئن شوید:
+
+```jsx
+
+ {tag}
+
+```
+
+کامنت کهنهٔ بالای بلوک («صفحهٔ فیلتر تگ وجود ندارد») حذف یا اصلاح شود؛ همان کامنت
+باعث شد این قابلیت جا بماند.
+
+استایل چیپ عوض نشود، فقط `hover` مناسب اضافه شود چون حالا کلیکشدنی است.
+
+**نحوه تست:** تست کامپوننتی روی `components/blog/head` — مقاله با دو تگ، دو
+`role="link"` با `href` شامل `/blogs?tag=`؛ مقاله بدون تگ، هیچ لیستی رندر نشود.
+سپس دستی: کلیک روی تگ باید به `/blogs?tag=…` برود و همان فیلتر اعمال شود.
+
+### ۵. رفع دفرمگی عکس مطالب مرتبط
+
+به ظرف عکس `shrink-0` اضافه شود تا در flex فشرده نشود:
+
+```jsx
+
+
+
+
+
+```
+
+اگر `CustomLoading` خودش ظرف flex میسازد، `shrink-0` باید روی همان بیرونیترین
+عنصرِ داخل `
` بنشیند — اول ساختار رندرشده را ببینید، بعد کلاس را جای درست بگذارید.
+
+**نحوه تست:** تست کامپوننتی که کلاس `shrink-0` روی ظرف عکس هست. بهعلاوه بررسی
+چشمی در مرورگر با یک تیتر بسیار بلند — jsdom چیدمان را اندازه نمیگیرد، پس این مورد
+را **صادقانه بهعنوان «بررسی چشمی» گزارش کنید**، نه بهعنوان تست خودکار.
+
+### ۶. بند عضویت پزشک در «درباره ما»
+
+یک بند سوم بعد از دو بند فعلی در `components/aboutUs/index.js` اضافه شود. متن
+پیشنهادی:
+
+```jsx
+
+ اگر پزشک هستید و میخواهید در {siteName} نوبتدهی آنلاین داشته باشید، ثبتنام
+ از طریق سامانهی کلینیکپرو انجام میشود. کافی است به{" "}
+
+ clinic-pro.ir
+ {" "}
+ مراجعه کنید و حساب مطب خود را بسازید. پس از تأیید، پروفایل و برنامهی
+ نوبتدهی شما روی {siteName} نمایش داده میشود.
+
@@ -27,6 +27,22 @@ async function AboutUsPage() {
نوبت خود را ثبت کنید. هدف ما کاهش زمان انتظار و ایجاد ارتباط مؤثر میان
بیماران، پزشکان و کلینیکهاست.
+
+ اگر پزشک هستید و میخواهید در {siteName} نوبتدهی آنلاین داشته باشید،
+ ثبتنام از طریق سامانهی کلینیکپرو انجام میشود. حساب مطب خود را
+ آنجا بسازید؛ پس از تأیید، پروفایل و برنامهی نوبتدهی شما روی{" "}
+ {siteName} نمایش داده میشود.
+
);
+
+ const links = screen.getAllByRole("link", { name: "قلب و عروق" });
+ const url = new URL(links[0].getAttribute("href"), "https://yasuj-nobat.ir");
+
+ expect(links).toHaveLength(1);
+ expect(url.pathname).toBe("/blogs");
+ // قرارداد واقعی همان چیزی است که `searchParams.get("tag")` میخواند،
+ // نه شکل دقیق encode — شکل شیئیِ href فاصله را `+` میکند و بردکرامب `%20`.
+ expect(url.searchParams.get("tag")).toBe("قلب و عروق");
+ });
+
+ it("تگ حاوی & با پارامتر دوم اشتباه گرفته نمیشود", () => {
+ render(
- {/* شهر پست در HTML قابلمشاهده — نه فقط در متادیتا. برچسب با همان
- تایپوگرافی «نویسنده/تاریخ» تا با بقیهٔ سربرگ همخانواده بماند. */}
- {cityName && (
-
{/* فهرست کامل تگها — تا این تغییر فقط تگ اول در بردکرامب دیده میشد.
- صفحهٔ فیلتر تگ وجود ندارد، پس چیپها بدون لینک رندر میشوند. */}
+ مقصد هر چیپ همان لیست مقالههاست که `?tag=` را میخواند؛ شکل شیئیِ href
+ تگِ حاوی فاصله یا & را هم درست encode میکند. */}
{tags.length > 0 && (
{tags.map((tag, idx) => (
-
- {tag}
+
+
+ {tag}
+
))}
diff --git a/components/blog/index.js b/components/blog/index.js
index 30df61c..4c51d41 100644
--- a/components/blog/index.js
+++ b/components/blog/index.js
@@ -2,10 +2,10 @@ import Detail from "./detail";
import Head from "./head";
import RelatedContent from "./relatedContent";
-function BlogPage({ blog, blogs, cityName }) {
+function BlogPage({ blog, blogs }) {
return (
+ {/* CustomLoading ظرف نمیسازد، پس فرزند مستقیمِ فلکس همین لینک است؛
+ بدون shrink-0 تیتر بلند عرض عکس را میبلعد و ارتفاعش ثابت میماند. */}
-
+
{
+ it("ظرف عکس در فلکس فشرده نمیشود", () => {
+ render();
+
+ const image = screen.getByRole("img", { name: DATA.title });
+
+ // jsdom چیدمان را اندازه نمیگیرد؛ آنچه قابل تست است همین قرارداد کلاس است.
+ expect(image.closest("a")).toHaveClass("shrink-0");
+ });
+
+ it("ستون متن اجازهٔ کوچک شدن دارد تا سرریز نکند", () => {
+ const { container } = render();
+
+ expect(container.querySelector("li > div")).toHaveClass("min-w-0");
+ });
+});
diff --git a/components/doctor/poster/PosterLight.js b/components/doctor/poster/PosterLight.js
index bdcd6c8..455a13c 100644
--- a/components/doctor/poster/PosterLight.js
+++ b/components/doctor/poster/PosterLight.js
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import DoctorAvatar from "@/app/component/DoctorAvatar";
import QrImg from "./QrImg";
-import SiteLogo from "./SiteLogo";
+import SiteLogo from "@/components/common/SiteLogo";
import { getStateInfoClient } from "@/lib/getStateInfoClient";
import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay";
import { doctorTitle } from "@/helper";
diff --git a/components/doctor/poster/index.js b/components/doctor/poster/index.js
index 99ce4a7..cae2d2b 100644
--- a/components/doctor/poster/index.js
+++ b/components/doctor/poster/index.js
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import DoctorAvatar from "@/app/component/DoctorAvatar";
import QrImg from "./QrImg";
-import SiteLogoBase from "./SiteLogo";
+import SiteLogoBase from "@/components/common/SiteLogo";
import { getStateInfoClient } from "@/lib/getStateInfoClient";
import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay";
import { doctorTitle } from "@/helper";
diff --git a/components/home/index.js b/components/home/index.js
index 8c16518..a0bd6e2 100644
--- a/components/home/index.js
+++ b/components/home/index.js
@@ -3,7 +3,6 @@ import Image from "next/image";
import SearchBar from "./search";
import TextHeader from "./TextHeader";
import FrequentSearches from "./FrequentSearches";
-import CityHighlights from "./CityHighlights";
function HomePage() {
return (
@@ -19,7 +18,6 @@ function HomePage() {
-
);
diff --git a/lib/cityIntro.test.js b/lib/cityIntro.test.js
index e7dc4d3..cb011f2 100644
--- a/lib/cityIntro.test.js
+++ b/lib/cityIntro.test.js
@@ -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 با آمار متفاوت، متن یکسان نمیگیرند", () => {
diff --git a/lib/listingIntro.js b/lib/listingIntro.js
index d1bb82c..78d5cb2 100644
--- a/lib/listingIntro.js
+++ b/lib/listingIntro.js
@@ -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
diff --git a/lib/routeParams.js b/lib/routeParams.js
new file mode 100644
index 0000000..e78195a
--- /dev/null
+++ b/lib/routeParams.js
@@ -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;
+ }
+}
diff --git a/lib/routeParams.test.js b/lib/routeParams.test.js
new file mode 100644
index 0000000..2541f0e
--- /dev/null
+++ b/lib/routeParams.test.js
@@ -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();
+ });
+});