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
@@ -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=` را می‌خواند و اعمال می‌کند.
### ۵. عکس «مطالب مرتبط» دفرمه است
عکس داخل `<li className="flex">` است و ظرفش `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
<div className="padding-responsive z-20 min-h-screen py-[120px] flex items-center justify-center flex-col gap-[40px]">
<TextHeader />
<SearchBar />
<FrequentSearches />
<CityHighlights />
</div>
```
### صفحهٔ تخصص — دو مصرف متفاوت از یک 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 && (
<div className="flex items-center justify-start gap-1">
<p className="text-[#9B9B9B] text-[11px] md:text-[13px] lg:text-[14px] font-normal">
مخصوص شهر:
</p>
<p className="text-[#9B9B9B] text-[11px] md:text-[14px] lg:text-[16px] font-medium">
{cityName}
</p>
</div>
)}
```
### تگ‌های بدون لینک
```jsx
// components/blog/head/index.js:90
{/* فهرست کامل تگ‌ها — تا این تغییر فقط تگ اول در بردکرامب دیده می‌شد.
صفحهٔ فیلتر تگ وجود ندارد، پس چیپ‌ها بدون لینک رندر می‌شوند. */}
{tags.length > 0 && (
<ul className="flex flex-wrap items-center justify-start gap-2 mt-[12px] md:mt-[16px]">
{tags.map((tag, idx) => (
<li
key={`${tag}-${idx}`}
className="bg-[#F5F5F5] text-[#525252] rounded-full px-[12px] py-[4px] text-[11px] md:text-[13px] font-medium"
>
{tag}
</li>
))}
</ul>
)}
```
ولی فیلتر تگ از قبل هست:
```js
// components/blogs/index.js:16
const selectedTag = searchParams.get("tag") || null;
// :32
params.tag = selectedTag;
```
### عکس مطالب مرتبط
```jsx
// components/blog/relatedContent/Item.js
<li className="flex w-full relative items-start justify-start gap-[8px] py-[12px]">
<CustomLoading width={80} height={72} loading={loading}>
<Link href={`/blog/${data.uuid}`}>
<img
className=" w-[64px] sm:w-[73px] md:w-[85px] lg:w-[97px] h-[56px] sm:h-[64px] md:h-[75px] lg:h-[88px] rounded-[8px] overflow-hidden object-cover "
src={cover}
alt={data.title || "article"}
/>
</Link>
</CustomLoading>
```
### هدر «درباره ما»
```jsx
// components/aboutUs/Head.js
<div className="bg-[#5559C2] rounded-none md:rounded-lg w-full py-[24px] flex flex-col items-center justify-center gap-[13px] md:gap-[16px]">
<Image
src="/assets/images/logo-2.png"
height={46}
width={46}
alt="logo"
/>
<h1 className="...">درباره {siteName}</h1>
{slogan && <p className="...">{slogan}</p>}
</div>
```
`Head` الان فقط `siteName` و `slogan` می‌گیرد؛ `matchedCity` به آن پاس نمی‌شود.
### متن «درباره ما»
```jsx
// components/aboutUs/index.js — دو بند، هر دو از دید بیمار
<p>{siteName} یک سامانهی آنلاین نوبتدهی پزشکی است که </p>
<p>در {siteName} میتوانید بر اساس تخصص، نام پزشک یا مرکز درمانی جستجو کنید </p>
```
## وظایف
### ۱. برداشتن بخش شهرمحور صفحهٔ اصلی
- `<CityHighlights />` و 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
<Link href={{ pathname: "/blogs", query: { tag } }} className="...">
{tag}
</Link>
```
کامنت کهنهٔ بالای بلوک («صفحهٔ فیلتر تگ وجود ندارد») حذف یا اصلاح شود؛ همان کامنت
باعث شد این قابلیت جا بماند.
استایل چیپ عوض نشود، فقط `hover` مناسب اضافه شود چون حالا کلیک‌شدنی است.
**نحوه تست:** تست کامپوننتی روی `components/blog/head` — مقاله با دو تگ، دو
`role="link"` با `href` شامل `/blogs?tag=`؛ مقاله بدون تگ، هیچ لیستی رندر نشود.
سپس دستی: کلیک روی تگ باید به `/blogs?tag=…` برود و همان فیلتر اعمال شود.
### ۵. رفع دفرمگی عکس مطالب مرتبط
به ظرف عکس `shrink-0` اضافه شود تا در flex فشرده نشود:
```jsx
<CustomLoading width={80} height={72} loading={loading}>
<Link href={`/blog/${data.uuid}`} className="block shrink-0">
<img className="... shrink-0 ..." ... />
</Link>
</CustomLoading>
```
اگر `CustomLoading` خودش ظرف flex می‌سازد، `shrink-0` باید روی همان بیرونی‌ترین
عنصرِ داخل `<li>` بنشیند — اول ساختار رندرشده را ببینید، بعد کلاس را جای درست بگذارید.
**نحوه تست:** تست کامپوننتی که کلاس `shrink-0` روی ظرف عکس هست. به‌علاوه بررسی
چشمی در مرورگر با یک تیتر بسیار بلند — jsdom چیدمان را اندازه نمی‌گیرد، پس این مورد
را **صادقانه به‌عنوان «بررسی چشمی» گزارش کنید**، نه به‌عنوان تست خودکار.
### ۶. بند عضویت پزشک در «درباره ما»
یک بند سوم بعد از دو بند فعلی در `components/aboutUs/index.js` اضافه شود. متن
پیشنهادی:
```jsx
<p>
اگر پزشک هستید و میخواهید در {siteName} نوبتدهی آنلاین داشته باشید، ثبتنام
از طریق سامانهی کلینیکپرو انجام میشود. کافی است به{" "}
<a
href="https://clinic-pro.ir/"
target="_blank"
rel="noopener noreferrer"
className="text-[#5559C2] font-medium underline underline-offset-4"
>
clinic-pro.ir
</a>{" "}
مراجعه کنید و حساب مطب خود را بسازید. پس از تأیید، پروفایل و برنامهی
نوبتدهی شما روی {siteName} نمایش داده میشود.
</p>
```
قواعد:
- نام سایت هاردکد نشود؛ همان `siteName` بالای فایل استفاده شود.
- لینک خارجی است، پس `next/link` لازم نیست؛ `<a>` با `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` باشد.
### ۷. اصلاح لوگوی هدر «درباره ما»
`<Image src="/assets/images/logo-2.png" />` با `SiteLogo` جایگزین شود:
```jsx
<SiteLogo city={matchedCity} stroke="#FAFAFA" className="w-[46px] h-[46px]" />
```
- `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` باید `<img>` با همان آدرس بدهد.
بررسی چشمی هم لازم است — دیده‌شدن لوگو روی بنفش را 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"`
می‌دهد. صفحات رندر می‌شوند و برای این تست‌ها کافی است، ولی اگر رفتار عجیبی دیدید
اول همین را در نظر بگیرید.
+12 -6
View File
@@ -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 }) {
/>
)}
<Layout>
<BlogPage blog={blog} blogs={relatedBlogs} cityName={blogCity?.name} />
<BlogPage blog={blog} blogs={relatedBlogs} />
</Layout>
</>
);
+5 -2
View File
@@ -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();
+4 -8
View File
@@ -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 (
<div className="bg-[#5559C2] rounded-none md:rounded-lg w-full py-[24px] flex flex-col items-center justify-center gap-[13px] md:gap-[16px]">
<Image
src="/assets/images/logo-2.png"
height={46}
width={46}
alt="logo"
/>
{/* رنگ پیش‌فرض لوگو #5559CE است و روی همین پس‌زمینه گم می‌شود؛ نسخهٔ روشن. */}
<SiteLogo city={city} stroke="#FAFAFA" className="w-[46px] h-[46px]" />
<h1 className="text-[#FAFAFA] text-[16px] sm:text-[18px] md:text-[22px] lg:text-[24px] font-bold">
درباره {siteName}
</h1>
+46
View File
@@ -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(
<Head siteName={CITY.site_name} slogan="کنار شما" city={CITY} />
);
const path = container.querySelector("svg path");
// پس‌زمینهٔ هدر #5559C2 است؛ رنگ پیش‌فرض لوگو روی آن دیده نمی‌شود.
expect(path).toHaveAttribute("stroke", "#FAFAFA");
});
it("شهر دارای logo_url همان تصویر را نشان می‌دهد", () => {
render(
<Head
siteName={CITY.site_name}
city={{ ...CITY, logo_url: "https://cdn.test/logo.png" }}
/>
);
expect(screen.getByRole("img", { name: "logo" })).toHaveAttribute(
"src",
"https://cdn.test/logo.png"
);
});
it("لوگوی قدیمی logo-2.png دیگر رندر نمی‌شود", () => {
const { container } = render(<Head siteName={CITY.site_name} city={CITY} />);
expect(container.innerHTML).not.toContain("logo-2");
});
it("شعار نداشتن، سربرگ را نمی‌شکند", () => {
render(<Head siteName={CITY.site_name} city={CITY} />);
expect(
screen.getByRole("heading", { name: `درباره ${CITY.site_name}` })
).toBeInTheDocument();
});
});
+17 -1
View File
@@ -11,7 +11,7 @@ async function AboutUsPage() {
<div
className=" pt-[24px] sm:pt-[56px] md:pt-[88px] lg:pt-[120px] mt-[56px] px-0 md:px-[80px] lg:px-[108px] "
>
<Head siteName={siteName} slogan={slogan} />
<Head siteName={siteName} slogan={slogan} city={matchedCity} />
<div
className=" text-[#525252] text-[16px] font-normal mt-[32px] leading-[28px] md:leading-[32px] lg:leading-[40px] px-[16px] sm:px-[52px] md:px-[15%] text-justify md:text-center flex flex-col gap-[16px] "
>
@@ -27,6 +27,22 @@ async function AboutUsPage() {
نوبت خود را ثبت کنید. هدف ما کاهش زمان انتظار و ایجاد ارتباط مؤثر میان
بیماران، پزشکان و کلینیکهاست.
</p>
<p>
اگر پزشک هستید و میخواهید در {siteName} نوبتدهی آنلاین داشته باشید،
ثبتنام از طریق سامانهی کلینیکپرو انجام میشود. حساب مطب خود را
آنجا بسازید؛ پس از تأیید، پروفایل و برنامهی نوبتدهی شما روی{" "}
{siteName} نمایش داده میشود.
</p>
<div className="flex justify-center mt-[8px]">
<a
href="https://clinic-pro.ir/"
target="_blank"
rel="noopener noreferrer"
className="px-[24px] py-[12px] rounded-lg bg-[#5559CE] border border-[#5559CE] text-[#FFF] text-[14px] md:text-[16px] font-medium hover:bg-[#FFF] hover:text-[#5559CE] transition-all duration-500"
>
ثبتنام پزشکان در کلینیکپرو
</a>
</div>
</div>
<Services />
</div>
+55
View File
@@ -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(<Head data={BLOG} />);
expect(screen.queryByText("مخصوص شهر:")).toBeNull();
});
it("نویسنده و تاریخ انتشار سر جایشان می‌مانند", () => {
render(<Head data={BLOG} />);
expect(screen.getByText("نویسنده:")).toBeInTheDocument();
expect(screen.getByText("تاریخ انتشار:")).toBeInTheDocument();
});
it("هر تگ به لیست مقاله‌های همان تگ لینک می‌شود", () => {
render(<Head data={BLOG} />);
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(<Head data={{ ...BLOG, tag: [{ name: "قلب & عروق" }] }} />);
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(<Head data={{ ...BLOG, tag: [] }} />);
expect(container.querySelector("ul")).toBeNull();
});
});
+10 -19
View File
@@ -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 }) {
</h1>
</div>
<div className="flex mt-[16px] sm:mt-[18px] md:mt-[21px] lg:mt-[24px] items-center justify-start gap-[35px]">
{/* شهر پست در HTML قابل‌مشاهده — نه فقط در متادیتا. برچسب با همان
تایپوگرافی «نویسنده/تاریخ» تا با بقیهٔ سربرگ هم‌خانواده بماند. */}
{cityName && (
<div className="flex items-center justify-start gap-1">
<p className="text-[#9B9B9B] text-[11px] md:text-[13px] lg:text-[14px] font-normal">
مخصوص شهر:
</p>
<p className="text-[#9B9B9B] text-[11px] md:text-[14px] lg:text-[16px] font-medium">
{cityName}
</p>
</div>
)}
{data?.author && (
<div className="flex items-center justify-start gap-1">
<p className="text-[#9B9B9B] text-[11px] md:text-[13px] lg:text-[14px] font-normal">
@@ -88,15 +76,18 @@ function Head({ data, cityName }) {
)}
</div>
{/* فهرست کامل تگ‌ها — تا این تغییر فقط تگ اول در بردکرامب دیده می‌شد.
صفحهٔ فیلتر تگ وجود ندارد، پس چیپ‌ها بدون لینک رندر می‌شوند. */}
مقصد هر چیپ همان لیست مقاله‌هاست که `?tag=` را می‌خواند؛ شکل شیئیِ href
تگِ حاوی فاصله یا & را هم درست encode می‌کند. */}
{tags.length > 0 && (
<ul className="flex flex-wrap items-center justify-start gap-2 mt-[12px] md:mt-[16px]">
{tags.map((tag, idx) => (
<li
key={`${tag}-${idx}`}
className="bg-[#F5F5F5] text-[#525252] rounded-full px-[12px] py-[4px] text-[11px] md:text-[13px] font-medium"
>
{tag}
<li key={`${tag}-${idx}`}>
<Link
href={{ pathname: "/blogs", query: { tag } }}
className="block bg-[#F5F5F5] text-[#525252] rounded-full px-[12px] py-[4px] text-[11px] md:text-[13px] font-medium hover:bg-[#E8E8E8] hover:text-[#3B3B3B] transition-colors"
>
{tag}
</Link>
</li>
))}
</ul>
+2 -2
View File
@@ -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 (
<div className="padding-responsive pt-[84px] sm:pt-[110px] md:pt-[140px] lg:pt-[168px]">
<Head data={blog} cityName={cityName} />
<Head data={blog} />
<div className="flex flex-col gap-y-[32px] lg:flex-row items-start justify-center gap-[24px] mt-[16px] sm:mt-[19px] md:mt-[21px] lg:mt-[24px]">
<Detail data={blog} />
<RelatedContent data={blogs} />
+3 -1
View File
@@ -10,8 +10,10 @@ function Item({ data, idx, loading }) {
return (
<li className="flex w-full relative items-start justify-start gap-[8px] py-[12px]">
{/* CustomLoading ظرف نمی‌سازد، پس فرزند مستقیمِ فلکس همین لینک است؛
بدون shrink-0 تیتر بلند عرض عکس را می‌بلعد و ارتفاعش ثابت می‌ماند. */}
<CustomLoading width={80} height={72} loading={loading}>
<Link href={`/blog/${data.uuid}`}>
<Link href={`/blog/${data.uuid}`} className="block shrink-0">
<img
className=" w-[64px] sm:w-[73px] md:w-[85px] lg:w-[97px] h-[56px] sm:h-[64px] md:h-[75px] lg:h-[88px] rounded-[8px] overflow-hidden object-cover "
src={cover}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import Item from "@/components/blog/relatedContent/Item";
const DATA = {
uuid: "8f1c0f2e-0000-4000-8000-000000000001",
title: "تیتری که بسیار بلند است و باید ستون متن را پر کند بدون آنکه عکس را باریک کند",
created: 1750000000,
};
describe("آیتم مطالب مرتبط", () => {
it("ظرف عکس در فلکس فشرده نمی‌شود", () => {
render(<Item data={DATA} idx={0} loading={false} />);
const image = screen.getByRole("img", { name: DATA.title });
// jsdom چیدمان را اندازه نمی‌گیرد؛ آنچه قابل تست است همین قرارداد کلاس است.
expect(image.closest("a")).toHaveClass("shrink-0");
});
it("ستون متن اجازهٔ کوچک شدن دارد تا سرریز نکند", () => {
const { container } = render(<Item data={DATA} idx={0} loading={false} />);
expect(container.querySelector("li > div")).toHaveClass("min-w-0");
});
});
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
-2
View File
@@ -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() {
<TextHeader />
<SearchBar />
<FrequentSearches />
<CityHighlights />
</div>
</div>
);
+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();
});
});