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

20 KiB
Raw Permalink Blame History

نمایش چندتخصصی پزشک در کارت، فیلترها و پوستر

پروژه

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 اضافه کن و گزارش بده.