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 +
    + + + + +
    +``` + +### صفحهٔ تخصص — دو مصرف متفاوت از یک param + +```jsx +// app/specialties/[slug]/page.js:18 +const findSpecialty = (slug) => + specialtiesData.find((item) => item.slug === slug && item.status === 1) ?? null; + +// :45 generateMetadata — اینجا decode شده می‌رسد +const { slug } = await params; +const specialty = findSpecialty(slug); +if (!specialty) notFound(); + +// :78 بدنهٔ صفحه — اینجا encode شده می‌رسد +const { slug } = await params; +const specialty = findSpecialty(slug); +if (!specialty) notFound(); +``` + +### برچسب شهر + +```jsx +// components/blog/head/index.js:49 +{cityName && ( +
    +

    + مخصوص شهر: +

    +

    + {cityName} +

    +
    +)} +``` + +### تگ‌های بدون لینک + +```jsx +// components/blog/head/index.js:90 +{/* فهرست کامل تگ‌ها — تا این تغییر فقط تگ اول در بردکرامب دیده می‌شد. + صفحهٔ فیلتر تگ وجود ندارد، پس چیپ‌ها بدون لینک رندر می‌شوند. */} +{tags.length > 0 && ( + +)} +``` + +ولی فیلتر تگ از قبل هست: + +```js +// components/blogs/index.js:16 +const selectedTag = searchParams.get("tag") || null; +// :32 +params.tag = selectedTag; +``` + +### عکس مطالب مرتبط + +```jsx +// components/blog/relatedContent/Item.js +
  • + + + {data.title + + +``` + +### هدر «درباره ما» + +```jsx +// components/aboutUs/Head.js +
    + logo +

    درباره {siteName}

    + {slogan &&

    {slogan}

    } +
    +``` + +`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} نمایش داده می‌شود. +

    +``` + +قواعد: + +- نام سایت هاردکد نشود؛ همان `siteName` بالای فایل استفاده شود. +- لینک خارجی است، پس `next/link` لازم نیست؛ `` با `target="_blank"` و + `rel="noopener noreferrer"` درست است. +- استایل بند از بقیه جدا نشود؛ همان ظرف `flex flex-col gap-[16px]` را می‌گیرد. + +**نحوه تست:** +```bash +NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build && npm run start & +curl -s -H "Host: yasuj-nobat.ir" http://localhost:3000/about-us | grep -c "clinic-pro.ir" # باید ≥ 1 +``` +به‌علاوه تست کامپوننتی: لینکی با `href="https://clinic-pro.ir/"` رندر شود و +`rel` شامل `noopener` باشد. + +### ۷. اصلاح لوگوی هدر «درباره ما» + +`` با `SiteLogo` جایگزین شود: + +```jsx + +``` + +- `matchedCity` باید از `components/aboutUs/index.js` به `Head` پاس شود؛ الان + فقط `siteName` و `slogan` می‌رود. +- `stroke="#FAFAFA"` لازم است چون رنگ پیش‌فرض روی پس‌زمینهٔ بنفش گم می‌شود. +- `crossOrigin="anonymous"` داخل `SiteLogo` برای حالت poster گذاشته شده و اینجا + ضرری ندارد؛ دست نزنید. +- اگر `SiteLogo` جای بهتری لازم دارد چون دیگر فقط مال poster نیست، جابه‌جایی‌اش + به `components/common/` قابل قبول است — ولی آن‌وقت هر دو مصرف‌کننده باید + به‌روز شوند و تست‌ها سبز بمانند. +- بعد از اصلاح، `public/assets/images/logo-2.png` هیچ مصرف‌کننده‌ای ندارد و پاک + می‌شود. **اول با grep ثابت کنید یتیم است، بعد پاک کنید.** + +**نحوه تست:** +```bash +grep -rn "logo-2" app components public --include="*.js" --include="*.json" # باید خالی باشد +``` +به‌علاوه تست کامپوننتی روی `components/aboutUs/Head`: شهر بدون `logo_url` باید +svg درون‌خطی بدهد، شهر با `logo_url` باید `` با همان آدرس بدهد. +بررسی چشمی هم لازم است — دیده‌شدن لوگو روی بنفش را jsdom نمی‌سنجد. آن را +**صادقانه به‌عنوان «بررسی چشمی» گزارش کنید**. + +## نکات مهم + +- **۴۰۴ اسلاگ فارسی، مهم‌ترین بخش این تسک است.** اگر فقط صفحهٔ تخصص درست شود و + صفحهٔ مقاله جا بماند، همان باگ با اولین مقالهٔ فارسی‌اسلاگ برمی‌گردد. هر دو صفحه + در همین تسک اصلاح شوند. +- **دلیل helper مشترک به‌جای دو تکه کد:** دو مصرف‌کنندهٔ فعلی و یک الگوی تکرارشونده. + این abstraction «برای آینده» نیست؛ همین حالا دو جا لازم است. +- **حذف کد مرده جزو کار است.** `buildCityIntro` بعد از برداشتن `CityHighlights` + بلااستفاده می‌شود. نگه‌داشتنش همان وضعیتی را می‌سازد که `buildDoctorsIntro` ساخته + بود: تابعی که تست دارد ولی هیچ‌جا رندر نمی‌شود. +- **سنجهٔ شباهت صفحهٔ اصلی برمی‌گردد به حدود ۹۰٪.** تسک قبلی آن را به ۷۱٫۵٪ رسانده + بود. این عقب‌گرد خواستهٔ کاربر است و باید در گزارش پایانی صریح ذکر شود، نه بی‌صدا. + مشکل `Duplicate, Google chose different canonical` روی صفحهٔ اصلی برمی‌گردد. +- **دو مورد «درباره ما» مستقل از پنج مورد قبلی‌اند.** اگر یکی از آن‌ها گیر کرد، + بقیه را کامل کنید و همان یکی را صریح گزارش دهید. +- **لوگو مسئلهٔ کنتراست است، نه فقط عوض‌کردن فایل.** stroke لوگو `#5559CE` و + پس‌زمینه `#5559C2` است. اگر بدون `stroke` روشن جایگزین کنید، تست‌ها سبز + می‌شوند ولی کاربر لوگو را نمی‌بیند. +- خط پایهٔ ریپو پیش از این تسک: `npm run test` چهار شکستِ ازقبل‌موجود در + `lib/lib.test.js` و `lib/getStateInfo.test.js`؛ `npm run lint` سه خطا در فایل‌های + بی‌ربط. این‌ها رگرسیون نیستند. +- `npm run start` هشدار `"next start" does not work with "output: standalone"` + می‌دهد. صفحات رندر می‌شوند و برای این تست‌ها کافی است، ولی اگر رفتار عجیبی دیدید + اول همین را در نظر بگیرید. diff --git a/app/blog/[slug]/page.js b/app/blog/[slug]/page.js index 1c878bb..7e4ba4e 100644 --- a/app/blog/[slug]/page.js +++ b/app/blog/[slug]/page.js @@ -11,6 +11,7 @@ import { findCityById, } from "@/lib/domainHelpers"; import { safeJsonLd } from "@/lib/sanitize"; +import { decodeRouteParam } from "@/lib/routeParams"; import { normalizeBlog, blogCover } from "@/helper"; const API_URL = process.env.NEXT_PUBLIC_API_URL; @@ -39,7 +40,8 @@ async function getScopedBlog(slug) { } export async function generateMetadata({ params }) { - const { slug } = await params; + const { slug: rawSlug } = await params; + const slug = decodeRouteParam(rawSlug); const { matchedCity } = await getStateInfo(); const siteName = matchedCity?.site_name || "نوبت 724"; @@ -82,7 +84,9 @@ export async function generateMetadata({ params }) { // دامنه سرو می‌شد و هرکدام خودش را canonical اعلام می‌کرد. // فیلد canonical_url اختصاصی (مثلاً نسخهٔ شهریِ پایپ‌لاین) مقدم است. const origin = getBlogCanonicalOrigin(extractEntityCityId(blog)); - const canonical = blog.canonical_url || `${origin}/blog/${slug}`; + // slug اینجا decode شده است؛ canonical باید شکل درصدی را بدهد وگرنه روی + // اسلاگ فارسی یک URL با کاراکتر غیر ASCII منتشر می‌شود. + const canonical = blog.canonical_url || `${origin}/blog/${encodeURIComponent(slug)}`; return { title, @@ -115,7 +119,9 @@ export async function generateMetadata({ params }) { } async function Blog({ params }) { - const { slug } = await params; + const { slug: rawSlug } = await params; + const slug = decodeRouteParam(rawSlug); + const slugPath = encodeURIComponent(slug); const blog = normalizeBlog(await getScopedBlog(slug)); if (!blog) notFound(); @@ -165,7 +171,7 @@ async function Blog({ params }) { headline: blog.title, ...(articleDescription && { description: articleDescription }), image: blogCover(blog), - mainEntityOfPage: { "@type": "WebPage", "@id": `${origin}/blog/${slug}` }, + mainEntityOfPage: { "@type": "WebPage", "@id": `${origin}/blog/${slugPath}` }, ...(blog.created && { datePublished: new Date(blog.created * 1000).toISOString(), }), @@ -216,7 +222,7 @@ async function Blog({ params }) { : []), // آیتم آخر بدون `item` کل BreadcrumbList را نامعتبر می‌کند { "@type": "ListItem", position: categoryName ? 4 : 3, name: blog.title, - item: `${origin}/blog/${slug}` }, + item: `${origin}/blog/${slugPath}` }, ], } : null; @@ -242,7 +248,7 @@ async function Blog({ params }) { /> )} - + ); diff --git a/app/specialties/[slug]/page.js b/app/specialties/[slug]/page.js index 942f777..8283c4e 100644 --- a/app/specialties/[slug]/page.js +++ b/app/specialties/[slug]/page.js @@ -10,6 +10,7 @@ import { buildSpecialtyFaq, buildSpecialtyIntro } from "@/lib/specialtyContent"; import { isCategorySpecialty } from "@/lib/specialtyIndexing"; import { safeJsonLd } from "@/lib/sanitize"; import { fetchReq } from "@/lib/req"; +import { decodeRouteParam } from "@/lib/routeParams"; import { isNextRedirectError } from "@/lib/maintenance"; const API_URL = process.env.NEXT_PUBLIC_API_URL; @@ -42,7 +43,8 @@ const getDoctors = cache(async (specialtyId, cityId) => { }); export async function generateMetadata({ params }) { - const { slug } = await params; + const { slug: rawSlug } = await params; + const slug = decodeRouteParam(rawSlug); const specialty = findSpecialty(slug); if (!specialty) notFound(); @@ -73,7 +75,8 @@ export async function generateMetadata({ params }) { } async function SpecialtyDetail({ params }) { - const { slug } = await params; + const { slug: rawSlug } = await params; + const slug = decodeRouteParam(rawSlug); const specialty = findSpecialty(slug); if (!specialty) notFound(); diff --git a/components/aboutUs/Head.js b/components/aboutUs/Head.js index 52739f9..cbfad1f 100644 --- a/components/aboutUs/Head.js +++ b/components/aboutUs/Head.js @@ -1,15 +1,11 @@ -import Image from "next/image"; import React from "react"; +import SiteLogo from "@/components/common/SiteLogo"; -function Head({ siteName, slogan }) { +function Head({ siteName, slogan, city }) { return (
    - logo + {/* رنگ پیش‌فرض لوگو #5559CE است و روی همین پس‌زمینه گم می‌شود؛ نسخهٔ روشن. */} +

    درباره {siteName}

    diff --git a/components/aboutUs/aboutUs.test.js b/components/aboutUs/aboutUs.test.js new file mode 100644 index 0000000..61b7859 --- /dev/null +++ b/components/aboutUs/aboutUs.test.js @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import Head from "@/components/aboutUs/Head"; + +const CITY = { name: "یاسوج", site_name: "یاسوج نوبت", logo_url: null }; + +describe("سربرگ درباره ما", () => { + it("شهر بدون logo_url لوگوی درون‌خطی با رنگ روشن می‌گیرد", () => { + const { container } = render( + + ); + + const path = container.querySelector("svg path"); + + // پس‌زمینهٔ هدر #5559C2 است؛ رنگ پیش‌فرض لوگو روی آن دیده نمی‌شود. + expect(path).toHaveAttribute("stroke", "#FAFAFA"); + }); + + it("شهر دارای logo_url همان تصویر را نشان می‌دهد", () => { + render( + + ); + + expect(screen.getByRole("img", { name: "logo" })).toHaveAttribute( + "src", + "https://cdn.test/logo.png" + ); + }); + + it("لوگوی قدیمی logo-2.png دیگر رندر نمی‌شود", () => { + const { container } = render(); + + expect(container.innerHTML).not.toContain("logo-2"); + }); + + it("شعار نداشتن، سربرگ را نمی‌شکند", () => { + render(); + + expect( + screen.getByRole("heading", { name: `درباره ${CITY.site_name}` }) + ).toBeInTheDocument(); + }); +}); diff --git a/components/aboutUs/index.js b/components/aboutUs/index.js index 0354215..48ecbfe 100644 --- a/components/aboutUs/index.js +++ b/components/aboutUs/index.js @@ -11,7 +11,7 @@ async function AboutUsPage() {
    - +
    @@ -27,6 +27,22 @@ async function AboutUsPage() { نوبت خود را ثبت کنید. هدف ما کاهش زمان انتظار و ایجاد ارتباط مؤثر میان بیماران، پزشکان و کلینیک‌هاست.

    +

    + اگر پزشک هستید و می‌خواهید در {siteName} نوبت‌دهی آنلاین داشته باشید، + ثبت‌نام از طریق سامانه‌ی کلینیک‌پرو انجام می‌شود. حساب مطب خود را + آنجا بسازید؛ پس از تأیید، پروفایل و برنامه‌ی نوبت‌دهی شما روی{" "} + {siteName} نمایش داده می‌شود. +

    +
    + + ثبت‌نام پزشکان در کلینیک‌پرو + +
    diff --git a/components/blog/head/blogHead.test.js b/components/blog/head/blogHead.test.js new file mode 100644 index 0000000..d0dda08 --- /dev/null +++ b/components/blog/head/blogHead.test.js @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import Head from "@/components/blog/head"; + +const BLOG = { + title: "آلرژی دارویی", + author: "دکتر رضایی", + created: 1750000000, + reading_time: 5, + tag: [{ name: "آلرژی" }, { name: "قلب و عروق" }], +}; + +describe("سربرگ مقاله", () => { + it("برچسب «مخصوص شهر» را نشان نمی‌دهد", () => { + render(); + + expect(screen.queryByText("مخصوص شهر:")).toBeNull(); + }); + + it("نویسنده و تاریخ انتشار سر جایشان می‌مانند", () => { + render(); + + expect(screen.getByText("نویسنده:")).toBeInTheDocument(); + expect(screen.getByText("تاریخ انتشار:")).toBeInTheDocument(); + }); + + it("هر تگ به لیست مقاله‌های همان تگ لینک می‌شود", () => { + render(); + + 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(); + + const links = screen.getAllByRole("link", { name: "قلب & عروق" }); + const url = new URL(links[0].getAttribute("href"), "https://yasuj-nobat.ir"); + + expect(url.searchParams.get("tag")).toBe("قلب & عروق"); + expect(Array.from(url.searchParams.keys())).toEqual(["tag"]); + }); + + it("مقالهٔ بدون تگ هیچ فهرست تگی رندر نمی‌کند", () => { + const { container } = render(); + + expect(container.querySelector("ul")).toBeNull(); + }); +}); diff --git a/components/blog/head/index.js b/components/blog/head/index.js index 23553e6..47f9ebd 100644 --- a/components/blog/head/index.js +++ b/components/blog/head/index.js @@ -4,7 +4,7 @@ import { convertTimestampToJalali } from "@/helper"; const crumbClass = "text-[#9B9B9B] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal"; -function Head({ data, cityName }) { +function Head({ data }) { const firstTag = data?.tag?.[0]; const createdDate = data?.created ? convertTimestampToJalali(data.created) : ""; // normalizeBlog تگ‌ها را به [{name}] تبدیل می‌کند؛ نامِ خالی حذف می‌شود. @@ -44,18 +44,6 @@ function Head({ data, cityName }) {
    - {/* شهر پست در HTML قابل‌مشاهده — نه فقط در متادیتا. برچسب با همان - تایپوگرافی «نویسنده/تاریخ» تا با بقیهٔ سربرگ هم‌خانواده بماند. */} - {cityName && ( -
    -

    - مخصوص شهر: -

    -

    - {cityName} -

    -
    - )} {data?.author && (

    @@ -88,15 +76,18 @@ function Head({ data, cityName }) { )}

    {/* فهرست کامل تگ‌ها — تا این تغییر فقط تگ اول در بردکرامب دیده می‌شد. - صفحهٔ فیلتر تگ وجود ندارد، پس چیپ‌ها بدون لینک رندر می‌شوند. */} + مقصد هر چیپ همان لیست مقاله‌هاست که `?tag=` را می‌خواند؛ شکل شیئیِ href + تگِ حاوی فاصله یا & را هم درست encode می‌کند. */} {tags.length > 0 && ( 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 (
    - +
    diff --git a/components/blog/relatedContent/Item.js b/components/blog/relatedContent/Item.js index a85f6e8..5415edb 100644 --- a/components/blog/relatedContent/Item.js +++ b/components/blog/relatedContent/Item.js @@ -10,8 +10,10 @@ function Item({ data, idx, loading }) { 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(); + }); +});