From 42ac6645d56c0cb13c9b32aef306743e237682e7 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 12 Jul 2026 10:00:03 +0330 Subject: [PATCH] feat(doctor profile): implement unclaimed doctor handling and UI adjustments --- .../prompt/unclaimed-doctor-public-page.md | 142 ++++++++++++++++++ app/doctor/[slug]/page.js | 5 +- app/sitemap.js | 2 +- components/doctor/detailDoctor/Title.js | 55 ++++--- .../detailDoctor/cards/comments/index.js | 16 +- 5 files changed, 195 insertions(+), 25 deletions(-) create mode 100644 .claude/prompt/unclaimed-doctor-public-page.md diff --git a/.claude/prompt/unclaimed-doctor-public-page.md b/.claude/prompt/unclaimed-doctor-public-page.md new file mode 100644 index 0000000..21539d6 --- /dev/null +++ b/.claude/prompt/unclaimed-doctor-public-page.md @@ -0,0 +1,142 @@ +# صفحه عمومی پزشک — رفتار الزامی برای پروفایل بدون صاحب (unclaimed) + +## پروژه + +`nobat724_front` (سایت عمومی) — **cross-repo**. پرامپت همتای backend: +`clinicpro/.claude/prompt/unclaimed-doctor-null-rating.md` +آن پرامپت backend را **اول** اجرا کن؛ بعد این. backend فیلد `point`/`satisfaction` را برای پروفایل غیر-claimed به `null` برمی‌گرداند و `owner_status` را در پاسخ می‌گذارد. + +## زمینه + +پزشکانِ ایمپورت‌شده از نظام پزشکی با `owner_status = "unclaimed"` می‌آیند: بدون عکس واقعی، بدون بیوگرافی، بدون آدرس/تلفن واقعی، صفر نظر. اما صفحه‌ی عمومی همان UI پزشک عادی را نشان می‌دهد — امتیاز پیش‌فرض جعلی، تیک تأیید، دکمه‌ی نوبت‌دهی مرده، ایندکس‌شدنِ صفحه‌ی نازک. این هم گمراه‌کننده است، هم برای سئو مضر (near-duplicate + thin content). + +هدف: وقتی `doctor.owner_status !== "claimed"` مجموعه‌ای از اصلاحات الزامی اعمال شود؛ و به‌محض claimed شدن، صفحه دقیقاً به حالت عادی برگردد (همه‌ی این شرط‌ها فقط با یک flag گیت می‌شوند، پس خودکار برمی‌گردند). + +> شرط واحد در همه‌ی وظایف: `const isUnclaimed = doctor?.owner_status !== "claimed";` (یعنی `unclaimed` یا `pending_transfer`). پروفایل موجودِ عادی `owner_status = "claimed"` دارد → هیچ تغییری نمی‌بیند. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `app/doctor/[slug]/page.js` | Server Component؛ fetch (`getDoctor` L13–22)، `generateMetadata` L39–80، JSON-LD `aggregateRating` L109–161 | +| `components/doctor/detailDoctor/Title.js` | نمایش امتیاز/رضایت (L69–78)، تیک تأیید `TickCircle` (L55–58) | +| `components/doctor/claim/index.js` | بخش claim موجود (بنر «این پروفایل بر اساس اطلاعات عمومی...»، گیت با `owner_status !== "unclaimed"` L44) | +| `components/doctor/detailDoctor/cards/comments/index.js` | بلوک نظرات | +| `app/sitemap.js` | `getDoctorUrls` L98–112 — همه‌ی پزشکان بدون فیلتر | +| `app/robots.js` | robots سراسری | + +## وضعیت فعلی + +نمایش امتیاز (`Title.js` L69–78) — همیشه، حتی وقتی `point` جعلی است: + +```jsx +امتیاز: {doctor?.point} +... +{doctor?.satisfaction}% رضایت کاربران +``` + +تیک تأیید (`Title.js` L55–58) — **بدون هیچ شرطی** برای همه‌ی پزشکان: + +```jsx + +کد نظام پزشکی: {doctor?.medical_system_code} +``` + +JSON-LD AggregateRating (`page.js` L123–130) — فقط با `point > 0` گیت شده: + +```jsx +...(Number(doctor.point) > 0 && { + aggregateRating: { + "@type": "AggregateRating", + ratingValue: doctor.point, + bestRating: "5", + ratingCount: comments?.length || 1, + }, +}), +``` + +`generateMetadata` (L39–80) — **هیچ `robots`/noindex ندارد**. +`sitemap.js` `getDoctorUrls` (L98–112) — برای هر پزشکِ دارای uuid یک `/doctor/${d.uuid}` می‌سازد، بدون فیلتر claim. + +## وظایف + +### ۱. مخفی‌کردن امتیاز/رضایت برای unclaimed (مهم‌ترین) + +در `Title.js`، بلوک امتیاز و رضایت (L69–78) فقط وقتی نمایش داده شود که امتیاز واقعی وجود دارد. چون backend اکنون `point`/`satisfaction` را `null` می‌کند: + +```jsx +{Number(doctor?.point) > 0 && ( + // بلوک «امتیاز: ...» و «...% رضایت کاربران» +)} +``` + +پس برای unclaimed (که `point === null`) اصلاً رندر نمی‌شود. مطمئن شو placeholder «بدون نمره»/«۰ رضایت» جایگزین نمی‌شود — کل بلوک حذف شود. + +### ۲. حذف JSON-LD AggregateRating برای unclaimed + +با null شدن `point` در backend، شرط موجود `Number(doctor.point) > 0` (page.js L123) خودش `aggregateRating` را حذف می‌کند. **تأیید کن** که با `point = null` این شرط false می‌شود و بلوک `review[]` (L131–138) هم وقتی نظر واقعی نیست خالی/حذف است. در صورت نیاز شرط را صریح‌تر کن: + +```jsx +...(!isUnclaimed && Number(doctor.point) > 0 && { aggregateRating: {...} }), +``` + +### ۳. تیک تأیید فقط برای claimed + +در `Title.js` L55–58، `TickCircle` را مشروط کن: + +```jsx +{!isUnclaimed && } +``` + +کد نظام پزشکی بماند، فقط علامت ✓ سبز برای غیر-claimed حذف شود. + +### ۴. noindex روی صفحه‌ی unclaimed + +در `generateMetadata` (`page.js` L39–80) وقتی پروفایل claimed نیست، robots را noindex کن: + +```jsx +const isUnclaimed = doctor?.owner_status !== "claimed"; +return { + // ... title/description/openGraph موجود + robots: isUnclaimed + ? { index: false, follow: true } + : undefined, +}; +``` + +(`follow: true` تا لینک‌های داخلی دنبال شوند ولی خود صفحه‌ی نازک ایندکس نشود.) + +### ۵. خارج‌کردن unclaimed از sitemap + +در `app/sitemap.js` `getDoctorUrls` (L98–112)، فقط پزشکان claimed را وارد کن: + +```js +.filter((d) => d?.uuid && d?.owner_status === "claimed") +.map((d) => ({ url: `${base}/doctor/${d.uuid}`, ... })) +``` + +مطمئن شو پاسخ list (`/api/v1/doctors`, `toListArray`) فیلد `owner_status` دارد (طبق پرامپت backend دارد). + +### ۶. برچسب «تأییدنشده» + CTA تصاحب + +بخش claim موجود است (`components/doctor/claim/index.js`, mount در `components/doctor/index.js` L45) و با `owner_status === "unclaimed"` نمایش داده می‌شود. کارها: +- یک برچسب کوچک «تأییدنشده» نزدیک نام/تیتر در `Title.js` برای `isUnclaimed` اضافه کن (متنی خنثی، مثلاً badge خاکستری «پروفایل تأییدنشده»). +- بنر claim فعلی گیتش `!== "unclaimed"` است؛ اگر `pending_transfer` هم باید CTA ببیند، شرط را به `owner_status !== "claimed"` گسترش بده (هماهنگ با `isUnclaimed`). در غیر این‌صورت بدون تغییر بماند. + +### ۷. مخفی‌کردن بلوک‌های خالی در حالت unclaimed + +- بلوک نظرات (`cards/comments/index.js`): وقتی `isUnclaimed` و صفر نظر واقعی، به‌جای «۰ از ۵ / بدون نمره» boilerplate، یا کل بلوک را مخفی کن یا یک متن زمینه‌ای «هنوز نظری ثبت نشده» نشان بده. تکرارِ پنج‌بار «بدون نمره» حذف شود. +- بلوک موقعیت مکانی (`cards/locations/index.js`) already وقتی آدرس واقعی نیست `null` برمی‌گرداند (L5) — نیازی به تغییر نیست، فقط تأیید کن آدرسِ خودکار «مطب دکتر ...» بدون مختصات، map/تلفن جعلی نشان نمی‌دهد. + +### ۸. (اختیاری، اولویت پایین) اسلاگ کلیدواژه‌دار + +فعلاً slug === uuid (page.js L15, L113؛ sitemap L105؛ CTA `ItemAppointment.js` L57). تغییر به اسلاگ خوانا (مثل `/doctor/سیده-شهلا-حسینی-پاتولوژی-یاسوج`) تغییر بزرگ و ریسک‌دار است (باید redirect uuid→slug، تغییر canonical، هماهنگی با backend برای resolve اسلاگ). **در این پرامپت پیاده نکن** — فقط به‌عنوان کار جدا یادداشت کن. اگر انجام شد، canonical و sitemap و لینک‌های داخلی همه باید هماهنگ شوند و uuid همچنان resolve بماند. + +## نکات مهم + +- **پاسخ API سه‌لایه است**: `getDoctor` با `json.data.data` می‌خواند (page.js L21). فیلد `owner_status` روی همین object داخلی است. +- **همه‌ی شرط‌ها با یک flag**: `owner_status !== "claimed"`. به‌محض claim شدن (backend → `'claimed'`), همه‌ی این تغییرات خودکار غیرفعال و صفحه عادی می‌شود. هیچ منطق «reset» جدا لازم نیست. +- App Router: `generateMetadata` باید `params` را `await` کند (الگوی پروژه). +- دکمه‌ی نوبت‌دهی («نوبت‌دهی غیرفعال») با `doctor.active`/`free_turn` گیت می‌شود، **نه** با claim. پزشکان ایمپورت‌شده در backend `active_doctor_appointment = true` دارند ولی slot واقعی ندارند → همان «غیرفعال» درست است. تغییرش نده مگر کاربر بخواهد. +- استایل: MUI v5 + Tailwind، RTL، Vazir. badge «تأییدنشده» را با همان توکن‌های رنگی خنثی پروژه بساز. +- بعد از تغییر: `npm run build` برای صحت (بدون خطای type/lint). diff --git a/app/doctor/[slug]/page.js b/app/doctor/[slug]/page.js index 9044c6b..1b7eccb 100644 --- a/app/doctor/[slug]/page.js +++ b/app/doctor/[slug]/page.js @@ -57,10 +57,12 @@ export async function generateMetadata({ params }) { `رزرو نوبت آنلاین دکتر ${doctor.name}${specialtyNames ? " متخصص " + specialtyNames : ""}${doctor.address ? " | " + doctor.address : ""}`.trim() ).slice(0, 160); const image = imageUrl(doctor.img?.[0]?.url) || FALLBACK_IMG; + const isUnclaimed = doctor.owner_status !== "claimed"; return { title, description, + ...(isUnclaimed && { robots: { index: false, follow: true } }), openGraph: { title, description, @@ -106,6 +108,7 @@ async function Doctor({ params }) { const addresses = doctor ? await getDoctorAddresses(doctor.id) : []; const specialtyNames = doctor?.specialties?.map((s) => s.name).join(" و ") || ""; + const isUnclaimed = doctor?.owner_status !== "claimed"; const jsonLd = doctor ? { "@context": "https://schema.org", @@ -120,7 +123,7 @@ async function Doctor({ params }) { url: imageUrl(doctor.img[0].url), }, }), - ...(Number(doctor.point) > 0 && { + ...(!isUnclaimed && Number(doctor.point) > 0 && { aggregateRating: { "@type": "AggregateRating", ratingValue: doctor.point, diff --git a/app/sitemap.js b/app/sitemap.js index 9ab07d2..0d9fd3c 100644 --- a/app/sitemap.js +++ b/app/sitemap.js @@ -98,7 +98,7 @@ function cityFilterParams(scope) { async function getDoctorUrls(baseUrl, scope) { const doctors = await fetchAllPages('/api/v1/doctors', cityFilterParams(scope)); return doctors - .filter((d) => d?.uuid) + .filter((d) => d?.uuid && d?.owner_status === 'claimed') .map((d) => withLastModified( { diff --git a/components/doctor/detailDoctor/Title.js b/components/doctor/detailDoctor/Title.js index 15b43ab..973d1bf 100644 --- a/components/doctor/detailDoctor/Title.js +++ b/components/doctor/detailDoctor/Title.js @@ -20,6 +20,8 @@ const SOCIAL_ICONS = { }; function Title({ doctor }) { + const isUnclaimed = doctor?.owner_status !== "claimed"; + const hasRating = Number(doctor?.point) > 0; return (
@@ -31,9 +33,16 @@ function Title({ doctor }) {
-

- {doctor?.name} -

+
+

+ {doctor?.name} +

+ {isUnclaimed && ( + + پروفایل تأییدنشده + + )} +

@@ -53,9 +62,11 @@ function Title({ doctor }) { )}
-
- -
+ {!isUnclaimed && ( +
+ +
+ )}

کد نظام پزشکی: {doctor?.medical_system_code} @@ -63,22 +74,24 @@ function Title({ doctor }) {

- -
-
- -

- امتیاز: {doctor?.point} -

+ {hasRating && ( + +
+
+ +

+ امتیاز: {doctor?.point} +

+
+
+ +

+ {doctor?.satisfaction}% رضایت کاربران +

+
-
- -

- {doctor?.satisfaction}% رضایت کاربران -

-
-
- + + )} {doctor?.social_media && Object.values(doctor.social_media).some(Boolean) && (
{Object.entries(doctor.social_media).map(([key, url]) => { diff --git a/components/doctor/detailDoctor/cards/comments/index.js b/components/doctor/detailDoctor/cards/comments/index.js index 018c7a7..0c31ddb 100644 --- a/components/doctor/detailDoctor/cards/comments/index.js +++ b/components/doctor/detailDoctor/cards/comments/index.js @@ -5,6 +5,10 @@ import Comment from "@/app/component/comment"; import ModalAnswer from "./modal/ModalAnswer"; function Comments({ doctor, comments, rateAggregate }) { + const isUnclaimed = doctor?.owner_status !== "claimed"; + const hasComments = comments?.length > 0; + const hideRatingBlock = isUnclaimed && !hasComments; + return ( - - + {hideRatingBlock ? ( +

+ هنوز نظری برای این پزشک ثبت نشده است. +

+ ) : ( + <> + + + + )}
); }