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:
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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} />
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user