feat(doctor profile): implement unclaimed doctor handling and UI adjustments
This commit is contained in:
@@ -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
|
||||||
|
<span>امتیاز: {doctor?.point}</span>
|
||||||
|
...
|
||||||
|
<span>{doctor?.satisfaction}% رضایت کاربران</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
تیک تأیید (`Title.js` L55–58) — **بدون هیچ شرطی** برای همهی پزشکان:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<TickCircle className="hidden lg:flex ..." />
|
||||||
|
<span>کد نظام پزشکی: {doctor?.medical_system_code}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 && <TickCircle className="hidden lg:flex ..." />}
|
||||||
|
```
|
||||||
|
|
||||||
|
کد نظام پزشکی بماند، فقط علامت ✓ سبز برای غیر-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).
|
||||||
@@ -57,10 +57,12 @@ export async function generateMetadata({ params }) {
|
|||||||
`رزرو نوبت آنلاین دکتر ${doctor.name}${specialtyNames ? " متخصص " + specialtyNames : ""}${doctor.address ? " | " + doctor.address : ""}`.trim()
|
`رزرو نوبت آنلاین دکتر ${doctor.name}${specialtyNames ? " متخصص " + specialtyNames : ""}${doctor.address ? " | " + doctor.address : ""}`.trim()
|
||||||
).slice(0, 160);
|
).slice(0, 160);
|
||||||
const image = imageUrl(doctor.img?.[0]?.url) || FALLBACK_IMG;
|
const image = imageUrl(doctor.img?.[0]?.url) || FALLBACK_IMG;
|
||||||
|
const isUnclaimed = doctor.owner_status !== "claimed";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
...(isUnclaimed && { robots: { index: false, follow: true } }),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -106,6 +108,7 @@ async function Doctor({ params }) {
|
|||||||
const addresses = doctor ? await getDoctorAddresses(doctor.id) : [];
|
const addresses = doctor ? await getDoctorAddresses(doctor.id) : [];
|
||||||
|
|
||||||
const specialtyNames = doctor?.specialties?.map((s) => s.name).join(" و ") || "";
|
const specialtyNames = doctor?.specialties?.map((s) => s.name).join(" و ") || "";
|
||||||
|
const isUnclaimed = doctor?.owner_status !== "claimed";
|
||||||
const jsonLd = doctor
|
const jsonLd = doctor
|
||||||
? {
|
? {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
@@ -120,7 +123,7 @@ async function Doctor({ params }) {
|
|||||||
url: imageUrl(doctor.img[0].url),
|
url: imageUrl(doctor.img[0].url),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
...(Number(doctor.point) > 0 && {
|
...(!isUnclaimed && Number(doctor.point) > 0 && {
|
||||||
aggregateRating: {
|
aggregateRating: {
|
||||||
"@type": "AggregateRating",
|
"@type": "AggregateRating",
|
||||||
ratingValue: doctor.point,
|
ratingValue: doctor.point,
|
||||||
|
|||||||
+1
-1
@@ -98,7 +98,7 @@ function cityFilterParams(scope) {
|
|||||||
async function getDoctorUrls(baseUrl, scope) {
|
async function getDoctorUrls(baseUrl, scope) {
|
||||||
const doctors = await fetchAllPages('/api/v1/doctors', cityFilterParams(scope));
|
const doctors = await fetchAllPages('/api/v1/doctors', cityFilterParams(scope));
|
||||||
return doctors
|
return doctors
|
||||||
.filter((d) => d?.uuid)
|
.filter((d) => d?.uuid && d?.owner_status === 'claimed')
|
||||||
.map((d) =>
|
.map((d) =>
|
||||||
withLastModified(
|
withLastModified(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const SOCIAL_ICONS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function Title({ doctor }) {
|
function Title({ doctor }) {
|
||||||
|
const isUnclaimed = doctor?.owner_status !== "claimed";
|
||||||
|
const hasRating = Number(doctor?.point) > 0;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col lg:flex-row items-center justify-start gap-[8px] lg:gap-[32px]">
|
<div className="flex flex-col lg:flex-row items-center justify-start gap-[8px] lg:gap-[32px]">
|
||||||
<CircularLoading width={100} height={100}>
|
<CircularLoading width={100} height={100}>
|
||||||
@@ -31,9 +33,16 @@ function Title({ doctor }) {
|
|||||||
</CircularLoading>
|
</CircularLoading>
|
||||||
<div className="flex flex-col items-center lg:items-start gap-[8px] lg:gap-[16px]">
|
<div className="flex flex-col items-center lg:items-start gap-[8px] lg:gap-[16px]">
|
||||||
<TextLoading width={90} height={20}>
|
<TextLoading width={90} height={20}>
|
||||||
<h1 className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
|
<div className="flex items-center gap-2">
|
||||||
{doctor?.name}
|
<h1 className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
|
||||||
</h1>
|
{doctor?.name}
|
||||||
|
</h1>
|
||||||
|
{isUnclaimed && (
|
||||||
|
<span className="text-[#737373] bg-[#F5F5F5] border border-[#E5E5E5] rounded-full px-2 py-0.5 text-[10px] md:text-[12px] font-medium whitespace-nowrap">
|
||||||
|
پروفایل تأییدنشده
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
<TextLoading width={100} height={20}>
|
<TextLoading width={100} height={20}>
|
||||||
<h2 className="text-[#525252] text-[14px] md:text-[16px] font-medium">
|
<h2 className="text-[#525252] text-[14px] md:text-[16px] font-medium">
|
||||||
@@ -53,9 +62,11 @@ function Title({ doctor }) {
|
|||||||
</TextLoading>
|
</TextLoading>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div className="hidden lg:flex">
|
{!isUnclaimed && (
|
||||||
<TickCircle />
|
<div className="hidden lg:flex">
|
||||||
</div>
|
<TickCircle />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<TextLoading width={120} height={20}>
|
<TextLoading width={120} height={20}>
|
||||||
<p className="text-[#525252] text-[12px] md:text-[14px] lg:text-[16px] font-medium">
|
<p className="text-[#525252] text-[12px] md:text-[14px] lg:text-[16px] font-medium">
|
||||||
کد نظام پزشکی: {doctor?.medical_system_code}
|
کد نظام پزشکی: {doctor?.medical_system_code}
|
||||||
@@ -63,22 +74,24 @@ function Title({ doctor }) {
|
|||||||
</TextLoading>
|
</TextLoading>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CustomLoading width={160} height={25}>
|
{hasRating && (
|
||||||
<div className="flex items-center justify-start gap-2">
|
<CustomLoading width={160} height={25}>
|
||||||
<div className="flex p-1 gap-0.5 items-start justify-center">
|
<div className="flex items-center justify-start gap-2">
|
||||||
<StarDI />
|
<div className="flex p-1 gap-0.5 items-start justify-center">
|
||||||
<p className="text-[#616161] text-[12px] md:text-[14px] font-normal">
|
<StarDI />
|
||||||
امتیاز: {doctor?.point}
|
<p className="text-[#616161] text-[12px] md:text-[14px] font-normal">
|
||||||
</p>
|
امتیاز: {doctor?.point}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex p-1 items-start gap-0.5">
|
||||||
|
<LikeDI />
|
||||||
|
<p className="text-[#616161] text-[12px] md:text-[14px] font-normal">
|
||||||
|
{doctor?.satisfaction}% رضایت کاربران
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex p-1 items-start gap-0.5">
|
</CustomLoading>
|
||||||
<LikeDI />
|
)}
|
||||||
<p className="text-[#616161] text-[12px] md:text-[14px] font-normal">
|
|
||||||
{doctor?.satisfaction}% رضایت کاربران
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CustomLoading>
|
|
||||||
{doctor?.social_media && Object.values(doctor.social_media).some(Boolean) && (
|
{doctor?.social_media && Object.values(doctor.social_media).some(Boolean) && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{Object.entries(doctor.social_media).map(([key, url]) => {
|
{Object.entries(doctor.social_media).map(([key, url]) => {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import Comment from "@/app/component/comment";
|
|||||||
import ModalAnswer from "./modal/ModalAnswer";
|
import ModalAnswer from "./modal/ModalAnswer";
|
||||||
|
|
||||||
function Comments({ doctor, comments, rateAggregate }) {
|
function Comments({ doctor, comments, rateAggregate }) {
|
||||||
|
const isUnclaimed = doctor?.owner_status !== "claimed";
|
||||||
|
const hasComments = comments?.length > 0;
|
||||||
|
const hideRatingBlock = isUnclaimed && !hasComments;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-[24px] sm:mt-[27px] md:mt-[29px] lg:mt-[32px]">
|
<div className="mt-[24px] sm:mt-[27px] md:mt-[29px] lg:mt-[32px]">
|
||||||
<a
|
<a
|
||||||
@@ -20,8 +24,16 @@ function Comments({ doctor, comments, rateAggregate }) {
|
|||||||
</p>
|
</p>
|
||||||
<ModalAnswer doctor={doctor} />
|
<ModalAnswer doctor={doctor} />
|
||||||
</div>
|
</div>
|
||||||
<Chart comments={comments} rateAggregate={rateAggregate} />
|
{hideRatingBlock ? (
|
||||||
<Comment comments={comments} doctor={doctor} />
|
<p className="mt-[16px] text-[#616161] text-[14px] md:text-[16px] font-normal">
|
||||||
|
هنوز نظری برای این پزشک ثبت نشده است.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Chart comments={comments} rateAggregate={rateAggregate} />
|
||||||
|
<Comment comments={comments} doctor={doctor} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user