docs(prompt): plan the multi-specialty UI work

Companion to clinicpro's doctor-multi-specialty-search prompt, which is already
merged and now returns specialties[].parent_id plus descendant-aware
specialty_id filtering.

Records what the code inspection turned up, so the implementation does not
rediscover it: data/specialties.json is a stale snapshot missing the five newest
children of جراحی عمومی, which is why /specialties/جراح-گوارش 404s and why the
parent/child UI cannot be built until it is synced at build time; the filters
modal force-selects the first child when a group is picked, silently narrowing
the search; and both poster variants slice specialties to a blind four, which
overflows the fixed 1080x1350 frame.

Not executed yet — committed so the plan is not carried around untracked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-08 16:49:43 +03:30
co-authored by Claude Opus 5
parent 29881eede0
commit 94c22de8dd
+422
View File
@@ -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
<TextLoading loading={loading} width={120} height={15}>
<p className="text-[#616161] text-[14px] font-normal">
تخصص:
{doctor?.specialties?.map(
(item, idx) =>
`${item.name} ${doctor.specialties.length === idx + 1 ? "" : "|"} `
)}
</p>
</TextLoading>
```
### پوستر — برش کور روی عدد ۴
```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
<div className="flex flex-wrap gap-[10px] mt-[16px]">
{specialties.map((s, i) => (
<span
key={i}
className="flex items-center rounded-full bg-[#F59E0B]/18 border border-[#F59E0B]/40 px-[18px] py-[9px]"
>
<span className="text-[#FCD9A0] text-[22px] font-semibold leading-none">
{s.name}
</span>
</span>
))}
</div>
```
کادر ثابت است، پس هر ردیف اضافه محتوای پایین را بیرون می‌اندازد:
```jsx
// components/doctor/poster/index.js:81
<div
dir="rtl"
className="relative w-[1080px] h-[1350px] overflow-hidden flex flex-col p-[64px] text-right"
```
## وظایف
### ۱. اسکریپت همگام‌سازی `data/specialties.json`
فایل جدید `scripts/sync-specialties.mjs`.
منبع: `GET ${NEXT_PUBLIC_API_URL}/api/v1/specialties`**عمومی است و توکن نمی‌خواهد**.
بدون پارامتر `parent_id` همهٔ تخصص‌های فعال را می‌دهد. پاسخ دولایه است:
`{ success, data: { data: [...] } }`.
هر آیتم دقیقاً شکل فایل فعلی را دارد: `id` و `uuid` و `name` و `slug` و `status` و `weight`
و `parent_id`.
```js
// scripts/sync-specialties.mjs
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const OUT = new URL("../data/specialties.json", import.meta.url);
const res = await fetch(`${API_URL}/api/v1/specialties`);
if (!res.ok) throw new Error(`specialties sync failed: HTTP ${res.status}`);
const json = await res.json();
const items = json?.data?.data ?? [];
// آرایهٔ خالی یعنی چیزی غلط است — فایل موجود را با آن بازنویسی نکن.
if (!Array.isArray(items) || items.length === 0) {
throw new Error("specialties sync returned an empty list; refusing to overwrite");
}
```
قبل از نوشتن، خروجی را با ترتیب پایدار مرتب کن (بر اساس `id`) تا diff فایل نویزی نشود،
و با دو فاصله و `\n` انتهایی بنویس تا با شکل فعلی فایل بخواند.
در `package.json` قلاب بزن:
```json
"prebuild": "node scripts/sync-specialties.mjs",
```
`dev` را وصل نکن — توسعهٔ آفلاین نباید به API گره بخورد.
**نحوه تست:**
```bash
cd nobat724_front
node scripts/sync-specialties.mjs
python3 -c "import json;d=json.load(open('data/specialties.json'));print(len(d), [x['slug'] for x in d if x.get('parent_id')==13])"
```
باید ۹۹ و شامل `جراح-گوارش` باشد. سپس با `NEXT_PUBLIC_API_URL` غلط اجرا کن و مطمئن شو
خطا می‌دهد و فایل دست‌نخورده می‌ماند.
بعد `npm run build` و باز کردن `/specialties/جراح-گوارش`.
### ۲. گزینهٔ «همه تخصص‌های X»
`filterList` در `helper/index.js` یک آیتمِ «همه» به ابتدای `childrenList` اضافه کند که
شناسه‌اش همان شناسهٔ والد است:
```js
const filteredChildrenList =
data && data.category
? [
// «همه» یعنی فیلتر روی خودِ گروه؛ بک‌اند specialty_id را به نوادگان گسترش می‌دهد.
{ id: data.category.id, name: `همه تخصص‌های ${data.category.name}`, parent_id: null },
...childrenList.filter(
(item) => 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` بلوک `<p>` تخصص با این جایگزین شود:
```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` است و بدون این، کلیک صفحهٔ پزشک را باز می‌کند.
- چیپ باید `<button type="button">` باشد با `aria-label` روشن مثل
`نمایش ${rest.length} تخصص دیگر`، نه `<span>` با `onClick`.
- ارتفاع کارت نباید تغییر کند. نام `primary` در یک خط با `truncate` بماند.
استایل چیپ با توکن‌های موجود همان فایل: `text-[12px]` و `rounded-[4px]` و
`bg-[#F8F8FF]` و `text-[#616161]` — همان چیزی که بلوک امتیاز و رضایت استفاده می‌کند.
رنگ یا کلاس تازه اضافه نکن.
**نحوه تست:** `npm run test` با یک تست کامپوننتی —
پزشک با ۶ تخصص: متن `جراحی عمومی` هست و `+5` هست؛ کلیک روی `+5` هر شش نام را نشان می‌دهد؛
پزشک با ۱ تخصص: هیچ `+` در DOM نیست؛ پزشک بدون تخصص: کرش نمی‌کند.
سپس دستی در موبایل روی `/doctors` — ارتفاع کارت جهانتاب با کارت کناری یکی باشد.
### ۵. پوستر — والد درشت، زیرتخصص‌ها یک خط متنی
**هر دو فایل** `components/doctor/poster/index.js` و `components/doctor/poster/PosterLight.js`.
هر دو دقیقاً همان `slice(0, 4)` را دارند.
منطق پوستر عمداً با کارت فرق دارد: در چاپ نه کلیک هست نه Popover، پس `+N` بی‌معناست.
در `lib/specialtyDisplay.js` یک تابع دوم بگذار:
```js
/**
* زیرتخصص‌ها به‌شکل یک خط متنی، بریده روی مرز کلمه با بودجهٔ کاراکتر.
* پوستر کادر ثابت دارد و overflow-hidden است؛ هر ردیف اضافه، بخش‌های پایین را بیرون می‌اندازد.
*/
export function posterSpecialtyLine(rest, budget = 90) {
const names = rest.map((s) => s.name);
const shown = [];
let used = 0;
for (const name of names) {
const cost = name.length + (shown.length ? 3 : 0); // ' · '
if (used + cost > budget) break;
shown.push(name);
used += cost;
}
const hidden = names.length - shown.length;
return { text: shown.join(" · "), hidden };
}
```
در پوستر: `primary` همان چیپ درشت فعلی بماند (فقط یکی، نه چهارتا).
زیرش یک `<p>` با `text-[20px]` و رنگ کم‌رنگ‌تر موجود (`#C7DBF2` در تیره، معادلش در روشن)
که `text` را نشان می‌دهد و اگر `hidden > 0` بود `و {hidden} تخصص دیگر` را به آن می‌چسباند.
روی ظرف زیرتخصص‌ها `max-h` بگذار معادل دو خط، تا حتی اگر بودجه اشتباه تنظیم شد،
کادر سرریز نکند.
**نحوه تست:** تست واحد `posterSpecialtyLine` — بودجهٔ کوچک با نام‌های بلند؛ آرایهٔ خالی
(`text` تهی و `hidden` صفر)؛ یک نام بلندتر از کل بودجه (نباید وسط کلمه بریده شود).
سپس دستی: پوستر دکتر جهانتاب را از صفحهٔ پزشک بساز و مطمئن شو در هر دو تم روشن و تیره
هیچ چیزی بیرون از کادر نیست.
### ۶. placeholder کادر جستجو
```js
// components/doctors/search/SearchField.js:58
placeholder="جستجوی نام پزشک ..."
```
به «جستجوی نام پزشک یا تخصص ...» تغییر کند. بک‌اند بعد از پرامپت همتا نام تخصص را هم می‌گردد
و placeholder فعلی دروغ می‌شود.
**نحوه تست:** تایپ `جراح گوارش` در کادر و دیدن دکتر جهانتاب در نتایج. این تست فقط بعد از
اجرای پرامپت بک‌اند معنا دارد.
## نکات مهم
- **ترتیب اجرا اجباری است.** وظیفهٔ ۳ و ۴ بدون `parent_id` در پاسخ API کار نمی‌کنند.
اگر بک‌اند هنوز اجرا نشده، `splitSpecialties` همیشه `items[0]` را برمی‌گرداند و
نتیجه ظاهراً درست ولی غیرقابل‌اتکا می‌شود.
- **وظیفهٔ ۱ پیش‌نیاز وظیفهٔ ۲ است.** بدون فایلِ به‌روز، فرزندهای ۱۶۷ تا ۱۷۱ در
`filterList` نیستند و گزینهٔ «همه» روی گروهی می‌نشیند که فرزندانش را نمی‌بیند.
- **یک قاعده، یک جا.** انتخاب «تخصص اصلی» فقط در `lib/specialtyDisplay.js`. اگر در
`ItemDoctor` یا پوستر دوباره نوشته شود، فردا دو جا واگرا می‌شوند. این همان دلیل ساختن
فایل است، نه abstraction برای آینده.
- **کلیک داخل `Link`.** بدون `stopPropagation` روی چیپ `+N`، هر بار که کاربر تخصص‌ها را
می‌بیند به صفحهٔ پزشک پرت می‌شود. این را حتماً تست کن.
- **پوستر دو فایل است.** `index.js` تیره و `PosterLight.js` روشن. اصلاح یکی و فراموشی
دیگری، باگ را نصفه رها می‌کند.
- **سئو.** `data/specialties.json` به `app/sitemap.js` هم خوراک می‌دهد. بعد از همگام‌سازی،
سایت‌مپ تخصص‌های تازه را می‌گیرد — این مطلوب است، ولی مطمئن شو اسکریپت در شکست، فایل را
خالی نمی‌کند وگرنه سایت‌مپ کوچک می‌شود و صفحات از ایندکس می‌افتند.
- **استایل.** MUI v5 و Tailwind و RTL و فونت Vazir. کلاس یا رنگ تازه اضافه نکن؛ از همان
توکن‌های موجود در همان فایل استفاده کن. راه‌حل با CSS موقت یا `!important` پذیرفته نیست.
- **صفحهٔ جزئیات پزشک عمداً خارج از محدوده است.** breadcrumb شکسته‌اش عارضهٔ فایل کهنه است و
با وظیفهٔ ۱ خودبه‌خود درست می‌شود. اگر بعد از وظیفهٔ ۱ باز هم شکسته بود، آیتم تازه به
todo اضافه کن و گزارش بده.