diff --git a/.claude/prompt/doctor-multi-specialty-ui.md b/.claude/prompt/doctor-multi-specialty-ui.md new file mode 100644 index 0000000..b5766b7 --- /dev/null +++ b/.claude/prompt/doctor-multi-specialty-ui.md @@ -0,0 +1,422 @@ +# نمایش چندتخصصی پزشک در کارت، فیلترها و پوستر + +## پروژه + +`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` است و بدون این، کلیک صفحهٔ پزشک را باز می‌کند. +- چیپ باید `