Files
nobat724_front/.claude/prompt/doctor-multi-specialty-ui.md
hamedandClaude Opus 5 94c22de8dd 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>
2026-08-08 16:49:43 +03:30

423 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# نمایش چندتخصصی پزشک در کارت، فیلترها و پوستر
## پروژه
`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 اضافه کن و گزارش بده.