# نمایش چندتخصصی پزشک در کارت، فیلترها و پوستر
## پروژه
`nobat724_front` — سایت عمومی نوبتدهی.
**cross-repo.** پرامپت همتا: `clinicpro/.claude/prompt/doctor-multi-specialty-search.md`.
**آن را اول اجرا کن.** وظیفهٔ ۳ اینجا به کلید `parent_id` در `specialties[]` پاسخ
`GET /api/v1/doctors` نیاز دارد که همانجا اضافه میشود.
## زمینه
هر پزشک چند تخصص دارد و ساختار دو سطحی والد/فرزند است. نمونهٔ واقعی — دکتر محمدباقر جهانتاب
شش تخصص دارد:
```
13 جراحی عمومی (والد)
14 جراحی پلاستیک و زیبایی
167 جراحی لاپاراسکوپی
168 جراح تیروئید
169 جراح گوارش
170 جراحی سرطانها
```
## مشکل / هدف
چهار چیز در سایت عمومی میشکند.
### ۱. `data/specialties.json` کهنه است — ریشهٔ بقیهٔ مشکلات
فایل ثابت است و با دیتابیس همگام نیست:
- دیتابیس ۹۹ تخصص دارد، فایل ۹۴ تا.
- پنج فرزندِ `جراحی عمومی` با شناسههای ۱۶۷ تا ۱۷۱ در فایل **نیستند** — دقیقاً همانهایی که
این پزشک دارد.
این فایل در ۹ جا استفاده میشود: `app/sitemap.js`، `app/specialties/[slug]/page.js`،
`app/doctor/[slug]/page.js`، `components/home/search/Fields.js`، `components/specialties/index.js`،
`components/specialties/list/ItemSpecialties.js`، `components/doctor/index.js`،
`components/clinics/index.js`، `helper/index.js`.
نتیجه: `/specialties/جراح-گوارش` برابر ۴۰۴ است، لینک breadcrumb صفحهٔ پزشک میشکند، و
تخصص در سایتمپ نیست. تا این حل نشود، ساختن UI والد/فرزند ممکن نیست.
### ۲. انتخاب گروه، بیصدا به اولین زیرتخصص محدود میشود
کاربر `جراحی عمومی` را میزند و بدون اینکه بداند، فیلتر روی `جراحی پلاستیک و زیبایی` مینشیند.
هیچ گزینهای برای «کل گروه» وجود ندارد.
### ۳. کارت پزشک همهٔ تخصصها را پشتسرهم چاپ میکند
در موبایل ارتفاع کارت باد میکند و شبکه بههم میریزد.
### ۴. پوستر سرریز میکند
چهار چیپ اول با نامهای بلند، به سه ردیف میروند، بخش hero بلند میشود و بخشهای پایین از
کادر ثابت `1080×1350` با `overflow-hidden` بیرون میزنند.
## معیار پذیرش
- ✅ موفق: بعد از `npm run build`، فایل `data/specialties.json` هر ۹۹ تخصص فعال را با
`parent_id` و `slug` دارد و `/specialties/جراح-گوارش` صفحه میدهد نه ۴۰۴.
- ✅ موفق: در مودال فیلترها، انتخاب گروه `جراحی عمومی` گزینهٔ پیشفرض
«همه تخصصهای جراحی عمومی» را میگذارد و درخواست با `specialty_id=13` میرود.
- ✅ موفق: کارت دکتر جهانتاب در موبایل و دسکتاپ فقط `جراحی عمومی +5` نشان میدهد و ارتفاعش با
کارت پزشک تکتخصصی یکی است.
- ✅ موفق: کلیک روی `+5` فهرست کامل شش تخصص را در Popover نشان میدهد و **صفحهٔ پزشک را باز نمیکند**.
- ✅ موفق: پوستر همان پزشک، هیچ محتوایی بیرون از کادر ۱۳۵۰ ندارد و تخصصها روی هم نمیافتند.
- ❌ خطا: پزشک بدون هیچ تخصص → کارت بدون بخش تخصص و بدون `+0`، نه `undefined` و نه کرش.
- ❌ خطا: اسکریپت همگامسازی وقتی API در دسترس نیست → build با پیام روشن شکست بخورد و فایل
موجود را با آرایهٔ خالی بازنویسی **نکند**.
- ⚠️ مرزی: پزشک با دقیقاً یک تخصص → فقط نام، بدون چیپ `+N`.
- ⚠️ مرزی: پزشک با دو تخصص ریشهٔ متفاوت (مثلاً `جراحی عمومی` و `داخلی`) → اولین ریشه نمایش،
بقیه در `+N`.
- ⚠️ مرزی: پزشکی که فقط زیرتخصص دارد و هیچ ریشهای ندارد → اولین تخصص آرایه نمایش داده شود.
- ⚠️ مرزی: پوستر پزشکی با نام تخصص خیلی بلند → بریدن روی مرز کلمه، نه وسط کلمه.
## فایلهای مرتبط
| فایل | نقش |
|------|-----|
| `scripts/sync-specialties.mjs` | **جدید** — ساخت `data/specialties.json` از API |
| `package.json` | قلاب `prebuild` |
| `lib/specialtyDisplay.js` | **جدید** — قاعدهٔ «تخصص اصلی» مشترک بین کارت و پوستر |
| `helper/index.js` | `filterList()` خط ۲۷۰ |
| `components/doctors/modal/Content.js` | انتخاب اجباری اولین فرزند |
| `components/doctors/modal/form/index.js` | دو `CateSelector` |
| `app/component/ItemDoctor.js` | کارت پزشک |
| `app/component/PopoverDate.js` | الگوی موجود Popover — تقلید کن، از نو ننویس |
| `components/doctor/poster/index.js` | پوستر تیره |
| `components/doctor/poster/PosterLight.js` | پوستر روشن — همان باگ |
| `components/doctors/search/SearchField.js` | placeholder کادر جستجو |
## وضعیت فعلی
### انتخاب اجباری اولین زیرتخصص
```js
// components/doctors/modal/Content.js:5
const changeSpecialty = (name, value) => {
const newFilter = { ...filter, [name]: value };
if (name === "category") {
if (value) {
const filtered = specialties
.filter((item) => item.parent_id)
.filter((item) => String(item.parent_id) === String(value.id));
newFilter.specialty = filtered[0] || null;
} else {
newFilter.specialty = null;
}
}
setFilter(newFilter);
setDataInURL(newFilter);
return newFilter;
};
```
و ساخت پارامتر، `specialty` را بر `category` ترجیح میدهد:
```js
// helper/index.js:482
if (newFilter.specialty?.id) {
params.specialty_id = newFilter.specialty.id;
} else if (newFilter.category?.id) {
params.specialty_id = newFilter.category.id;
}
```
```js
// helper/index.js:270
export const filterList = (data) => {
const parentList = specialties.filter((item) => !item.parent_id);
const childrenList = specialties.filter((item) => item.parent_id);
const filteredChildrenList =
data && data.category
? childrenList.filter(
(item) => String(item.parent_id) === String(data.category.id)
)
: [];
return {
parentList: parentList,
childrenList: filteredChildrenList,
};
};
```
### کارت — همه را پشتسرهم چاپ میکند
```jsx
// app/component/ItemDoctor.js:46
```
### پوستر — برش کور روی عدد ۴
```jsx
// components/doctor/poster/index.js:44 (و PosterLight.js:24 دقیقاً همین)
const specialties = (data?.specialties ?? []).filter((s) => s?.name).slice(0, 4);
```
```jsx
// components/doctor/poster/index.js:132
{specialties.map((s, i) => (
{s.name}
))}
```
کادر ثابت است، پس هر ردیف اضافه محتوای پایین را بیرون میاندازد:
```jsx
// components/doctor/poster/index.js:81
String(item.parent_id) === String(data.category.id)
),
]
: [];
```
و در `Content.js` بهجای `filtered[0]`، همان آیتم «همه» انتخاب شود:
```js
if (name === "category") {
// پیشفرض «کل گروه» است، نه اولین زیرتخصص. انتخاب بیصدای اولین فرزند،
// جستجوی کاربر را بدون اطلاعش تنگ میکرد.
newFilter.specialty = value
? { id: value.id, name: `همه تخصصهای ${value.name}` }
: null;
}
```
`QueryForDoctorsReq` را دست نزن — چون شناسهٔ «همه» همان شناسهٔ والد است، همان مسیر فعلی
`specialty_id` را درست میفرستد.
**نحوه تست:** unit test در `helper/specialtyFilter.test.js` برای `filterList` —
با `category` برابر `جراحی عمومی` اولین آیتم `childrenList` باید `id` والد و عنوان
«همه تخصصهای جراحی عمومی» داشته باشد؛ بدون `category` آرایه خالی بماند.
سپس دستی: مودال فیلترها → گروه `جراحی عمومی` → در تب شبکه ببین `specialty_id=13` میرود
و دکتر جهانتاب در نتایج هست.
### ۳. قاعدهٔ مشترک «تخصص اصلی»
فایل جدید `lib/specialtyDisplay.js`. کارت و پوستر هر دو از این میخوانند تا قاعده دو جا
تکرار و واگرا نشود.
```js
/**
* تخصص «اصلی» و بقیه.
*
* ریشه (بدون parent_id) اصلی است چون عنوانی است که بیمار میشناسد و هنگام ذخیره در
* بکاند خودکار به پزشک اضافه میشود، پس تقریباً همیشه وجود دارد. اگر ریشهای نبود،
* اولین آیتم آرایه.
*/
export function splitSpecialties(list) {
const items = (list ?? []).filter((s) => s?.name);
if (items.length === 0) return { primary: null, rest: [] };
const primary = items.find((s) => !s.parent_id) ?? items[0];
return { primary, rest: items.filter((s) => s !== primary) };
}
```
**نحوه تست:** `lib/specialtyDisplay.test.js` — آرایهٔ خالی؛ تکتخصص؛ ریشه وسط آرایه؛
هیچ ریشهای نبودن؛ آیتمِ بدون `name` که باید حذف شود.
### ۴. کارت پزشک — تخصص اصلی و `+N`
در `app/component/ItemDoctor.js` بلوک `
` تخصص با این جایگزین شود:
```jsx
const { primary, rest } = splitSpecialties(doctor?.specialties);
```
نمایش: نام `primary`، و اگر `rest.length > 0` یک چیپ کوچک `+{rest.length}` کنارش.
کلیک روی چیپ، `Popover` باز کند با فهرست کامل (`primary` و `rest`).
الگوی `Popover` را از `app/component/PopoverDate.js` بردار، از صفر ننویس.
سه نکتهٔ اجباری:
- روی `onClick` چیپ حتماً `e.preventDefault()` و `e.stopPropagation()` بزن. کارت داخل
`Link` است و بدون این، کلیک صفحهٔ پزشک را باز میکند.
- چیپ باید `