Files
nobat724_front/.claude/prompt/nobat724-search-fix-and-seo.md
T

13 KiB
Raw Blame History

اصلاحات Nobat724: رفع خطای جستجوی پزشک + بهینه‌سازی SEO صفحات لیست و تخصص

پروژه

nobat724_front

این پرامپت جایگزین fix-search-autocomplete-doctor-name.md است (تسک ۱ همان باگ را پوشش می‌دهد) و چهار تسک SEO را هم اضافه می‌کند. هر تسک مستقل است؛ می‌توانی جدا کامیت کنی.

زمینه

سایت عمومی نوبت‌دهی چند-دامنه‌ای (هر شهر یک دامنه، مثل yasuj-nobat.ir، و دامنهٔ ریشه nobat724.com). پنج اصلاح لازم است: یک باگ جستجوی UI و چهار مورد بهینه‌سازی SEO روی صفحات لیست پزشکان و صفحات فرود تخصص.


تسک ۱ — رفع خطای getOptionLabel در جستجوی نام پزشک

مشکل

در صفحهٔ اصلی، تایپ «دکتر مهدیه» در باکس جستجو این ارور را می‌دهد:

MUI: The `getOptionLabel` method of Autocomplete returned undefined instead of a string for "دکتر مهدیه".

علت: Autocomplete با freeSolo رندر شده و options={specialtiesData} (اشیاء تخصص با فیلد name). وقتی کاربر متنی تایپ می‌کند که در options نیست، MUI مقدار تایپ‌شده را به‌صورت رشته به getOptionLabel می‌دهد؛ "دکتر مهدیه".name می‌شود undefined. دیتای specialties.json سالم است (هر ۹۳ آیتم name دارند) — منشأ ارور رشتهٔ freeSolo است، نه آیتم بدون name.

فایل

components/searchHead/smSearch/AutoCompleteSearch.js

وضعیت فعلی

getOptionLabel={(option) => option.name}          // خط ۲۷ — روی رشته می‌شکند
...
onChange={(_, newValue) => {
  setSelectedOption && setSelectedOption(newValue);
  handleSearch(newValue ? newValue.name : "");    // خط ۳۶ — newValue می‌تواند رشته باشد
}}

راه‌حل

هر دو نقطه را برای رشتهٔ freeSolo امن کن؛ همیشه string برگردان:

getOptionLabel={(option) =>
  typeof option === "string" ? option : option?.name ?? ""
}
...
onChange={(_, newValue) => {
  setSelectedOption && setSelectedOption(newValue);
  const label =
    typeof newValue === "string" ? newValue : newValue?.name ?? "";
  handleSearch(label);
}}

renderOption (خط ۳۹–۴۳) فقط برای options واقعی صدا زده می‌شود؛ دست نزن.

تست

  • تایپ «دکتر مهدیه» → بدون ارور؛ کلیک جستجو → هدایت به /doctors?...name=دکتر مهدیه (منطق redirect در components/home/search/RedirectLink.js از قبل درست است: match نشدن با تخصص → filters.name).
  • انتخاب یک تخصص از لیست (مثل «داخلی») → /doctors?...specialty=داخلی.
  • خالی‌کردن ورودی → بدون ارور.

تسک ۲ — noindex, follow برای همهٔ صفحات جستجوی /doctors?...

هدف

هر /doctors که کوئری‌پارامتر داشته باشد (?specialty=, ?city=, ?state=, ?name=, یا هر ترکیبی) باید robots: noindex, follow بگیرد. فقط /doctors بدونِ کوئری باید ایندکس شود. دلیل: صفحات جستجو داینامیک و Duplicate/Thin هستند؛ صفحهٔ فرود قابل‌ایندکسِ تخصص باید /specialties/[slug] باشد (تسک ۳)، نه /doctors?specialty=.

فایل

lib/listingRobots.js (توسط app/doctors/page.js خط ۲۵ و صفحهٔ /clinics مصرف می‌شود).

وضعیت فعلی

const USER_FILTER_KEYS = ["specialty", "gender", "degree", "active", "sort"];

export function listingRobots(params, { matchedCity, matchedState } = {}) {
  if (params?.name) return { robots: { index: false, follow: true } };
  const activeFilters = USER_FILTER_KEYS.filter((key) => params?.[key]);
  if (params?.city && params.city !== matchedCity?.name) activeFilters.push("city");
  if (params?.state && params.state !== matchedState?.name) activeFilters.push("state");
  if (activeFilters.length >= 2) return { robots: { index: false, follow: true } };
  return {};
}

منطق فعلی عمداً «تک‌فیلتر specialty» و «city/state تزریق‌شده از دامنه» را ایندکس‌پذیر نگه می‌داشت. طبق خواستهٔ جدید این رفتار باید عوض شود: هر کوئری‌پارامتر واقعی → noindex.

راه‌حل

منطق را ساده و سخت‌گیرانه کن: اگر بعد از حذف کلیدهای خالی، هیچ پارامتری نمانده → {} (ایندکس)؛ در غیر این صورت → noindex, follow.

// هر جستجوی /doctors با کوئری واقعی noindex,follow می‌شود؛ فقط /doctors خام ایندکس.
export function listingRobots(params) {
  const hasQuery =
    params &&
    Object.entries(params).some(
      ([, v]) => v !== undefined && v !== null && v !== ""
    );
  return hasQuery ? { robots: { index: false, follow: true } } : {};
}

نکتهٔ حیاتی — مرز URL vs. فیلترِ سرور

در app/doctors/page.js روی دامنهٔ شهری، city/state در URL نیستند؛ سرور آن‌ها را فقط داخل fetch تزریق می‌کند (newSearchParams)، ولی generateMetadata تابع را با awaitedParams = await searchParams (پارامترهای واقعی URL) صدا می‌زند. پس /doctors خام روی yasuj-nobat.ir همچنان بدون کوئری و ایندکس‌پذیر می‌ماند. قبل از پیاده‌سازی این را در همان فایل تأیید کن (خط ۹–۲۵) که listingRobots با پارامترهای خام URL صدا زده می‌شود، نه با newSearchParams.

بعد از تغییر امضا (حذف آرگومان دوم)، فراخوان در app/doctors/page.js و هر مصرف‌کنندهٔ دیگر (/clinics) را هم‌راستا کن — grep -rn "listingRobots" app بگیر و همه را بررسی کن.


تسک ۳ — قابل‌ایندکس‌بودن /specialties و صفحات تخصص

وضعیت فعلی (اکثراً درست)

  • app/specialties/page.js — هیچ override روباتی ندارد → ایندکس‌پذیر. نیازی به تغییر نیست.
  • app/specialties/[slug]/page.js — فقط وقتی total === 0 است noindex,follow می‌دهد (thin content)، وگرنه ایندکس.

فقط قانون تسک ۴ باید به [slug] اضافه شود. کاری اضافه در این تسک لازم نیست جز اطمینان از اینکه بعد از تسک ۲، صفحات /specialties/[child-slug] (مثل endourology, gastroenterology) ایندکس‌پذیر می‌مانند.


تسک ۴ — فقط تخصص‌های فرزند ایندکس شوند (والدها noindex)

هدف

تخصص‌های والد (parent_id === null، مثل «داخلی») صفحاتشان دسته‌بندی کلی و کم‌محتوا است و با فرزندان در نتایج گوگل رقابت می‌کنند. باید:

  • /specialties/internal-medicine (والد، parent_id: null) → noindex, follow
  • /specialties/gastroenterology (فرزند، parent_id: 2) → index

فایل

app/specialties/[slug]/page.js — تابع generateMetadata (خط ۴۳–۶۹).

وضعیت فعلی

const { total } = await getDoctors(specialty.id, isRoot ? null : matchedCity?.id);

return {
  title,
  description,
  ...(total === 0 && { robots: { index: false, follow: true } }),
  openGraph: { title, description, images: [image] },
  twitter: { card: "summary_large_image", title, description, images: [image] },
};

specialty از findSpecialty(slug) می‌آید و شیء کامل specialties.json است، پس specialty.parent_id در دسترس است.

راه‌حل

والد (parent_id === null) را هم به شرط noindex اضافه کن. توجه: parent_id ممکن است null باشد — از مقایسهٔ صریح استفاده کن.

const isParentCategory = specialty.parent_id == null; // والد یا بدون parent
const noindex = isParentCategory || total === 0;

return {
  title,
  description,
  ...(noindex && { robots: { index: false, follow: true } }),
  openGraph: { title, description, images: [image] },
  twitter: { card: "summary_large_image", title, description, images: [image] },
};

نکته

  • اگر sitemap.js صفحات تخصص را لیست می‌کند، مطمئن شو والدها آنجا هم حذف/فیلتر می‌شوند تا با noindex هم‌خوان باشد. grep -rn "specialties" app/sitemap.js و در صورت وجود، والدها (parent_id == null) را از خروجی sitemap کنار بگذار.

تسک ۵ — اصلاح نگارشی متن SEO سوالات متداول تخصص + برند روی دامنهٔ ریشه

فایل

lib/specialtyContent.js — تابع buildSpecialtyFaq (خط ۹۸–۱۲۰). مصرف‌کننده: app/specialties/[slug]/page.js خط ۸۶.

وضعیت فعلی

export function buildSpecialtyFaq(specialtyName, cityName, doctorCount = 0) {
  return [
    {
      question: `چطور از متخصص ${specialtyName} در ${cityName} نوبت بگیرم؟`,
      ...

روی دامنهٔ ریشه cityName برابر "ایران" است (از resolveCityDisplayNameROOT_DISPLAY_NAME)، پس سوال می‌شود: «چطور از متخصص اینترونشنال کاردیولوژی در ایران نوبت بگیرم؟».

خواسته

۱. جملهٔ سوال به شکل نگارشیِ روان‌تر تغییر کند: «چطور از … نوبت بگیرم؟» → «چطور می‌توان از … نوبت گرفت؟». ۲. روی دامنهٔ ریشه، به‌جای «ایران» برند «نوبت724» استفاده شود (تقویت برند + طبیعی‌تر). روی دامنهٔ شهری همان نام شهر بماند.

راه‌حل

بدون تغییر ROOT_DISPLAY_NAME سراسری (که در Titleها استفاده می‌شود و «متخصص X در ایران» آنجا درست است)، فقط داخل FAQ برند را جایگزین کن. یک آرگومان اختیاری isRoot به تابع اضافه کن:

export function buildSpecialtyFaq(specialtyName, cityName, doctorCount = 0, { isRoot = false } = {}) {
  const loc = isRoot ? "نوبت724" : cityName;
  return [
    {
      question: `چطور می‌توان از متخصص ${specialtyName} در ${loc} نوبت گرفت؟`,
      answer: `از فهرست همین صفحه پزشک موردنظرتان را انتخاب کنید، وارد پروفایل او شوید و از تقویم نوبت‌های خالی، تاریخ و ساعت دلخواه را ثبت کنید. رزرو در همان لحظه قطعی می‌شود و تأییدیه برایتان پیامک می‌شود.`,
    },
    {
      question: `چند پزشک ${specialtyName} در ${loc} در دسترس است؟`,
      answer:
        doctorCount > 0
          ? `در حال حاضر ${doctorCount} پزشک ${specialtyName} در ${loc} در این سامانه ثبت شده‌اند. این عدد با افزوده‌شدن پزشکان جدید به‌روزرسانی می‌شود.`
          : `فهرست پزشکان ${specialtyName} در ${loc} در حال تکمیل است و به‌مرور پزشکان جدید به آن افزوده می‌شوند.`,
    },
    // بقیهٔ سوالات (هزینه ویزیت / لغو نوبت) بدون تغییر
    ...
  ];
}

سپس در app/specialties/[slug]/page.js خط ۸۶، isRoot را پاس بده:

const faq = buildSpecialtyFaq(specialty.name, cityName, total, { isRoot });

(isRoot از قبل در همان تابع از getStateInfo() گرفته شده — خط ۷۶.)

نکات

  • فقط FAQ برند را عوض کند؛ buildSpecialtyIntro و Titleها دست‌نخورده بمانند (روی ریشه همچنان «در ایران»). این عمدی است تا فقط متن سوالِ موردِ اشارهٔ کاربر اصلاح شود.
  • این متن هم در بلوک بصری صفحه و هم در FAQPage JSON-LD استفاده می‌شود (منبع مشترک) — پس اصلاح یک‌جا هر دو را می‌پوشاند.

نکات کلی پروژه

  • App Router / Next.js 15؛ همیشه await params و await searchParams.
  • بعد از تغییرِ listingRobots، robots.js/sitemap.js را برای ناسازگاری بررسی کن (صفحات noindex نباید در sitemap باشند).
  • RTL فارسی، MUI v5 + Tailwind، فونت Vazir؛ استایل جدید اضافه نکن.
  • بعد از اتمام: graphify update . برای به‌روز نگه‌داشتن گراف (بعد از کامیت).