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>
20 KiB
نمایش چندتخصصی پزشک در کارت، فیلترها و پوستر
پروژه
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 کادر جستجو |
وضعیت فعلی
انتخاب اجباری اولین زیرتخصص
// 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 ترجیح میدهد:
// 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;
}
// 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,
};
};
کارت — همه را پشتسرهم چاپ میکند
// 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>
پوستر — برش کور روی عدد ۴
// components/doctor/poster/index.js:44 (و PosterLight.js:24 دقیقاً همین)
const specialties = (data?.specialties ?? []).filter((s) => s?.name).slice(0, 4);
// 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>
کادر ثابت است، پس هر ردیف اضافه محتوای پایین را بیرون میاندازد:
// 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.
// 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 قلاب بزن:
"prebuild": "node scripts/sync-specialties.mjs",
dev را وصل نکن — توسعهٔ آفلاین نباید به API گره بخورد.
نحوه تست:
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 اضافه کند که
شناسهاش همان شناسهٔ والد است:
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]، همان آیتم «همه» انتخاب شود:
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. کارت و پوستر هر دو از این میخوانند تا قاعده دو جا
تکرار و واگرا نشود.
/**
* تخصص «اصلی» و بقیه.
*
* ریشه (بدون 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> تخصص با این جایگزین شود:
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 یک تابع دوم بگذار:
/**
* زیرتخصصها بهشکل یک خط متنی، بریده روی مرز کلمه با بودجهٔ کاراکتر.
* پوستر کادر ثابت دارد و 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 کادر جستجو
// 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 اضافه کن و گزارش بده.