# نمایش چندتخصصی پزشک در کارت، فیلترها و پوستر ## پروژه `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

تخصص: {doctor?.specialties?.map( (item, idx) => `${item.name} ${doctor.specialties.length === idx + 1 ? "" : "|"} ` )}

``` ### پوستر — برش کور روی عدد ۴ ```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` است و بدون این، کلیک صفحهٔ پزشک را باز می‌کند. - چیپ باید `