feat(doctor): fix map render + claim modal captcha/mobile + owner delete
- Doctor page map: the visible locations card read doctor.address (empty
from the detail endpoint) while coordinates live in the separately
fetched addresses. Thread `addresses` (with map.latitude/longitude) down
page → DoctorPage → DetailDoctor → Locations; card hidden when empty
- Claim modal: updated info-box text ("نوبتهای این پروفایل عمومی و غیرخاص
هستند")، added mobile field (validated, must match account), added ALTCHA
widget (submit disabled until captcha resolves; payload sent as `altcha`)
- Owner delete: services.deleteDoctor + a guarded two-step "حذف این پروفایل"
in the claim success screen (owner enforced server-side; 403/409 shown)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
# رفع نمایش نقشه صفحه پزشک + بهبود مودال claim (کپچا/موبایل) + حذف پروفایل توسط مالک
|
||||
|
||||
## پروژه
|
||||
|
||||
`nobat724_front` (سایت عمومی)
|
||||
> پرامپت همتا (backend اول): `clinicpro/.claude/prompt/doctor-map-claim-captcha-delete.md` — کپچا و فیلد mobile روی endpoint claim، و اجازهٔ حذف پروفایل به مالک. این پرامپت آن قرارداد را مصرف میکند.
|
||||
|
||||
## زمینه
|
||||
|
||||
سه موضوع در صفحهٔ پزشک سایت:
|
||||
1. نقشه در صفحهٔ پزشک درست نمایش داده نمیشود (مثال: `/doctor/ab747d75-2114-42b8-9e6d-abdaa338edbe`).
|
||||
2. مودال «تأیید و مدیریت پروفایل» (claim) از قبل هست ولی طبق سناریو باید متن کادر اطلاعرسانی بهروز شود، **فیلد موبایل** و **کپچای ALTCHA** اضافه شود.
|
||||
3. پس از مالکشدن، پزشک باید دکمهٔ **حذف پروفایل** داشته باشد.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `app/doctor/[slug]/page.js` | فچ `doctor` + `getDoctorAddresses(doctor.id)` (خط ۲۴، ۱۰۵) |
|
||||
| `components/doctor/detailDoctor/cards/locations/index.js` | کارت آدرسها — از `doctor?.address` میخواند (خط ۱۱) |
|
||||
| `components/doctor/detailDoctor/cards/locations/Item.js` | رندر iframe گوگل با `data.map.latitude/longitude` (خط ۴۶، ۶۵) |
|
||||
| `components/doctor/claim/index.js` | `ClaimProfileSection` موجود (سکشن + مودال) |
|
||||
| `components/Altcha.js` | کامپوننت ALTCHA موجود سایت |
|
||||
| `services/response.js` | `getDoctorClaimInfo` / `postDoctorClaim` موجود |
|
||||
|
||||
## وظیفه ۱ — رفع نمایش نقشه صفحه پزشک
|
||||
|
||||
### ریشه (تأییدشده)
|
||||
|
||||
کارت آدرسها از **`doctor?.address`** میخواند:
|
||||
|
||||
```jsx
|
||||
// components/doctor/detailDoctor/cards/locations/index.js:11
|
||||
{doctor?.address?.map((item, idx) => ( <Item data={item} /> ))}
|
||||
```
|
||||
|
||||
اما مختصات نقشه در پاسخِ **جداگانه**ی `GET /api/v1/clinic-pro/doctor-addresses/{id}` است (backend آن را با شکل `map: { latitude, longitude }` برمیگرداند). در صفحه، این پاسخ در متغیر `addresses` فچ میشود و فقط در **JSON-LD** استفاده شده (`app/doctor/[slug]/page.js:105,141`)، ولی به کارت دیداری آدرسها **پاس داده نمیشود**. پس `doctor.address` یا خالی است یا `map` ندارد → `Item` شرط `data?.map?.latitude` را رد میکند → نقشه هرگز نمایش داده نمیشود.
|
||||
|
||||
### راهحل
|
||||
|
||||
`addresses` (که `map.latitude/longitude` دارد) را به همان کارتی که نقشه را رندر میکند برسان:
|
||||
|
||||
- در `app/doctor/[slug]/page.js`، `addresses` را به `DoctorPage`/`detailDoctor` پاس بده (prop جدید یا ادغام در `doctor.address`).
|
||||
- در `locations/index.js`، بهجای `doctor?.address` از همان آرایهٔ `addresses` استفاده کن که هر آیتم `map: { latitude, longitude }` و `address`/`telephone` دارد.
|
||||
- شکل مصرفی `Item` (`data.map.latitude`, `data.map.longitude`, `data.address`, `data.telephone`) را با شکل خروجی `doctor-addresses` همتراز کن (backend همین کلیدها را میدهد — تأیید در `clinicpro/src/Doctor/Controller/DoctorController.php:166`).
|
||||
|
||||
```jsx
|
||||
// locations/index.js — نمونه
|
||||
export default function Locations({ addresses }) {
|
||||
if (!addresses?.length) return null;
|
||||
return (<>{addresses.map((item, idx) => <Item key={idx} data={item} />)}</>);
|
||||
}
|
||||
```
|
||||
|
||||
- **edge:** پزشکِ ایمپورتشده (مثل نمونهٔ کاربر) آدرس ندارد → آرایه خالی → کارت اصلاً رندر نشود (نه نقشهٔ خراب). این درست است.
|
||||
- **edge:** آدرسِ بدون مختصات (`latitude=null`) → دکمهٔ مسیریابی/iframe نمایش داده نشود، ولی خود آدرس/تلفن نمایش داده شود.
|
||||
|
||||
## وظیفه ۲ — بهبود مودال claim (متن، موبایل، کپچا)
|
||||
|
||||
`components/doctor/claim/index.js` از قبل سکشن + مودال دارد. تغییرات:
|
||||
|
||||
**الف) متن کادر اطلاعرسانی** (طبق سناریو):
|
||||
|
||||
> «این پروفایل بر اساس اطلاعات عمومی سازمان نظام پزشکی ایجاد شده است و هنوز توسط پزشک تأیید و مدیریت نمیشود. نوبتهای این پروفایل عمومی و غیرخاص هستند.
|
||||
> آیا شما این پزشک هستید؟» + دکمهٔ «تأیید و مدیریت این پروفایل».
|
||||
|
||||
**ب) فیلد موبایل در فرم:** علاوه بر نام/نامخانوادگی/کد ملی/تاریخ تولد، فیلد **شماره موبایل** اضافه شود (پیشپرشده از کاربر لاگینشده اگر در دسترس است). به بدنهٔ `postDoctorClaim` اضافه شود:
|
||||
|
||||
```js
|
||||
await request.postDoctorClaim(doctor.uuid, {
|
||||
national_code, birth_date, first_name, last_name, mobile, altcha, // ← mobile و altcha جدید
|
||||
});
|
||||
```
|
||||
|
||||
**ج) کپچای ALTCHA:** از کامپوننت موجود `components/Altcha.js` استفاده کن. تا وقتی کاربر کپچا را حل نکرده، دکمهٔ ارسال **غیرفعال** بماند:
|
||||
|
||||
```jsx
|
||||
import Altcha from "@/components/Altcha";
|
||||
// ...
|
||||
const [altcha, setAltcha] = useState("");
|
||||
// در فرم:
|
||||
<Altcha onVerified={setAltcha} />
|
||||
<Button disabled={loading || !altcha} onClick={submit}>تأیید هویت و تصاحب پروفایل</Button>
|
||||
```
|
||||
|
||||
- payload کپچا را در بدنهٔ claim بفرست (backend همتا `CaptchaGuard::assertValid` را چک میکند). نام فیلد را با آنچه `CaptchaGuard` انتظار دارد هماهنگ کن (بررسی `AuthController` سایت/بکاند — معمولاً `altcha`).
|
||||
- اگر `ALTCHA_ENABLED=false` (dev) بکاند کپچا را نادیده میگیرد؛ ولی UI کپچا را نشان بده تا در prod کار کند.
|
||||
|
||||
**stateهای موجود مودال** (loading/error/success/double-submit/پیام خوشآمد) حفظ شوند؛ فقط فیلدها و کپچا اضافه میشوند. خطای `ERR_CAPTCHA_001` از بکاند → پیام «تأیید امنیتی ناموفق بود، دوباره تلاش کنید».
|
||||
|
||||
## وظیفه ۳ — حذف پروفایل توسط مالک
|
||||
|
||||
پس از claim موفق (پزشک مالک شد)، در صفحهٔ مدیریت پروفایل پزشک (یا همان صفحهٔ پزشک وقتی کاربرِ لاگینشده مالک است) دکمهٔ **«حذف پروفایل»** نمایش داده شود.
|
||||
|
||||
- فقط وقتی نمایش داده شود که کاربرِ لاگینشده مالکِ `claimed` این پروفایل باشد (از `owner_status` + تطبیق کاربر). مرجع نهایی مجوز، backend است.
|
||||
- کلیک → **دیالوگ تأیید** با پیام هشدار (طبق سناریو: «قبل از حذف، پیام هشدار نمایش داده شود»)، سپس:
|
||||
|
||||
```js
|
||||
// متد جدید در services/response.js
|
||||
deleteDoctor: (uuid) => api.delete(`api/v1/doctor/${uuid}`, { requireAuth: true }),
|
||||
```
|
||||
|
||||
- backend اجازهٔ حذف مالک را میدهد (پرامپت همتا). خطاها: ۴۰۳ (مالک نیست)، ۴۰۹ (پزشک نوبت ثبتشده دارد) → پیام فارسی مناسب.
|
||||
- پس از حذف موفق → هدایت به صفحهٔ اصلی/پنل + toast موفقیت.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **backend اول اجرا شود** (کپچا + فیلد mobile + delete مالک) وگرنه این تغییرات ۴۲۲/۴۰۳ میگیرند.
|
||||
- multi-domain: مودال claim کامپوننت مشترک است و روی همهٔ دامنهها/زیردامنهها کار میکند؛ منطق را per-domain تکرار نکن.
|
||||
- RTL، فارسی، Vazir، date-picker شمسی موجود؛ کتابخانهٔ جدید اضافه نکن.
|
||||
- هیچ درخواستی از فرانت به API سازمان (شاهکار/ثبتاحوال) نرود؛ همه backend.
|
||||
- تست:
|
||||
```bash
|
||||
cd nobat724_front && npm run lint && npm run build
|
||||
# صفحهٔ پزشکِ دارای آدرس با مختصات → نقشه نمایش داده شود؛
|
||||
# پزشک ایمپورتشدهٔ بدون آدرس → کارت نقشه رندر نشود (نه خراب)؛
|
||||
# مودال claim → کپچا اجباری، فیلد موبایل، ارسال موفق؛ دکمهٔ حذف فقط برای مالک.
|
||||
```
|
||||
@@ -196,6 +196,7 @@ async function Doctor({ params }) {
|
||||
doctor={doctor}
|
||||
comments={comments}
|
||||
rateAggregate={rateAggregate}
|
||||
addresses={addresses}
|
||||
slug={slug}
|
||||
/>
|
||||
</Layout>
|
||||
|
||||
@@ -9,8 +9,9 @@ import JalaliDatePicker from "@/components/common/JalaliDatePicker";
|
||||
import CloseModalD from "@/components/icons/CloseModalD";
|
||||
import { convertToJalali, isUserLoggedIn } from "@/helper";
|
||||
import { request } from "@/services/response";
|
||||
import Altcha from "@/components/Altcha";
|
||||
|
||||
const initialForm = { national_code: "", birth_date: "", first_name: "", last_name: "" };
|
||||
const initialForm = { national_code: "", birth_date: "", first_name: "", last_name: "", mobile: "" };
|
||||
|
||||
/**
|
||||
* احراز مالکیت پروفایل پزشک ایمپورتشده (unclaimed).
|
||||
@@ -26,6 +27,9 @@ function ClaimProfileSection({ doctor }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [claimed, setClaimed] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [altcha, setAltcha] = useState(null); // null=در حال آمادهسازی، ""=خاموش، payload=حلشده
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
if (doctor?.owner_status !== "unclaimed" || claimed) {
|
||||
return null;
|
||||
@@ -45,6 +49,8 @@ function ClaimProfileSection({ doctor }) {
|
||||
if (!form.birth_date) errs.birth_date = "تاریخ تولد را انتخاب کنید";
|
||||
if (!form.first_name.trim()) errs.first_name = "نام الزامی است";
|
||||
if (!form.last_name.trim()) errs.last_name = "نام خانوادگی الزامی است";
|
||||
const mob = form.mobile.replace(/[۰-۹]/g, (d) => "۰۱۲۳۴۵۶۷۸۹".indexOf(d));
|
||||
if (!/^09\d{9}$/.test(mob)) errs.mobile = "شماره موبایل نامعتبر است";
|
||||
setFieldErrors(errs);
|
||||
return Object.keys(errs).length === 0;
|
||||
};
|
||||
@@ -54,7 +60,7 @@ function ClaimProfileSection({ doctor }) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await request.postDoctorClaim(doctor.uuid, form);
|
||||
await request.postDoctorClaim(doctor.uuid, { ...form, altcha });
|
||||
setLoading(false);
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
@@ -70,12 +76,30 @@ function ClaimProfileSection({ doctor }) {
|
||||
router.push("/dashboard");
|
||||
};
|
||||
|
||||
const removeProfile = async () => {
|
||||
if (deleting) return;
|
||||
setDeleting(true);
|
||||
setError("");
|
||||
try {
|
||||
await request.deleteDoctor(doctor.uuid);
|
||||
setDeleting(false);
|
||||
setOpen(false);
|
||||
setClaimed(true);
|
||||
router.push("/");
|
||||
} catch (err) {
|
||||
setDeleting(false);
|
||||
const first = err?.response?.data?.errors?.[0];
|
||||
setError(first?.message || "حذف پروفایل ممکن نشد.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full mt-[16px] p-[20px] border border-solid border-[#E9ECEF] rounded-[16px] bg-[#FFF8ED]">
|
||||
<p className="text-[#3B3B3B] text-[14px] leading-7">
|
||||
این پروفایل بر اساس اطلاعات عمومی سازمان نظام پزشکی ایجاد شده و هنوز
|
||||
توسط پزشک تأیید و مدیریت نمیشود؛ نوبتدهی آنلاین آن غیرفعال است.
|
||||
این پروفایل بر اساس اطلاعات عمومی سازمان نظام پزشکی ایجاد شده است و
|
||||
هنوز توسط پزشک تأیید و مدیریت نمیشود. نوبتهای این پروفایل عمومی و
|
||||
غیرخاص هستند.
|
||||
</p>
|
||||
<div className="flex items-center justify-between flex-wrap gap-3 mt-[12px]">
|
||||
<p className="text-[#3B3B3B] text-[16px] font-bold">آیا شما این پزشک هستید؟</p>
|
||||
@@ -111,6 +135,43 @@ function ClaimProfileSection({ doctor }) {
|
||||
<Button variant="contained" className="!rounded-[12px] w-full" onClick={closeAfterSuccess}>
|
||||
ورود به پنل مدیریت
|
||||
</Button>
|
||||
{error && (
|
||||
<p className="text-[#e0394a] text-[13px] leading-6">{error}</p>
|
||||
)}
|
||||
{confirmDelete ? (
|
||||
<div className="w-full flex flex-col gap-[8px]">
|
||||
<p className="text-[#e0394a] text-[13px] leading-6">
|
||||
آیا از حذف کامل این پروفایل مطمئن هستید؟ این عمل قابل بازگشت نیست.
|
||||
</p>
|
||||
<div className="flex gap-[8px]">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
className="!rounded-[12px] flex-1"
|
||||
disabled={deleting}
|
||||
onClick={removeProfile}
|
||||
>
|
||||
{deleting ? "در حال حذف…" : "بله، حذف کن"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
className="!rounded-[12px] flex-1"
|
||||
disabled={deleting}
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[#e0394a] text-[13px] hover:underline"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
حذف این پروفایل
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : !loggedIn ? (
|
||||
<div className="flex flex-col items-center gap-[16px] mt-[24px] text-center">
|
||||
@@ -155,6 +216,15 @@ function ClaimProfileSection({ doctor }) {
|
||||
inputProps={{ maxLength: 10, dir: "ltr" }}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="شماره موبایل"
|
||||
value={form.mobile}
|
||||
error={!!fieldErrors.mobile}
|
||||
helperText={fieldErrors.mobile || "باید با شماره حساب کاربری شما یکی باشد"}
|
||||
onChange={(e) => setField("mobile", e.target.value)}
|
||||
inputProps={{ maxLength: 11, dir: "ltr" }}
|
||||
fullWidth
|
||||
/>
|
||||
<JalaliDatePicker
|
||||
value={form.birth_date}
|
||||
placeholder="تاریخ تولد (شمسی)"
|
||||
@@ -162,13 +232,14 @@ function ClaimProfileSection({ doctor }) {
|
||||
stepwise
|
||||
onChange={(e) => setField("birth_date", convertToJalali(e._d))}
|
||||
/>
|
||||
<Altcha onVerified={setAltcha} />
|
||||
{error && (
|
||||
<p className="text-[#e0394a] text-[13px] leading-6 text-center">{error}</p>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
className="!rounded-[12px]"
|
||||
disabled={loading}
|
||||
disabled={loading || altcha === null}
|
||||
onClick={submit}
|
||||
>
|
||||
{loading ? "در حال بررسی هویت…" : "تأیید هویت و تصاحب پروفایل"}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import CustomLoading from "@/app/component/loading/Custom";
|
||||
import Item from "./Item";
|
||||
|
||||
function Locations({ doctor }) {
|
||||
function Locations({ addresses }) {
|
||||
if (!addresses?.length) return null;
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[#525252] text-[16px] md:text-[18px] lg:text-[20px] font-bold">
|
||||
موقعیت مکانی{" "}
|
||||
</p>
|
||||
<ul className="flex w-full mt-[24px] flex-col justify-center items-start">
|
||||
{doctor?.address?.map((item, idx) => (
|
||||
{addresses.map((item, idx) => (
|
||||
<CustomLoading key={idx} width="full" height={40}>
|
||||
<li className="w-full">
|
||||
<Item data={item} />
|
||||
{doctor?.address.length > idx + 1 && (
|
||||
{addresses.length > idx + 1 && (
|
||||
<span className="w-full h-px bg-[#EFEFEF] block my-[12px] md:my-[14px] lg:my-[16px]"></span>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -5,7 +5,7 @@ import AboutDcotor from "./cards/AboutDcotor";
|
||||
import Comments from "./cards/comments";
|
||||
import Locations from "./cards/locations";
|
||||
|
||||
function DetailDoctor({ doctor, comments, rateAggregate }) {
|
||||
function DetailDoctor({ doctor, comments, rateAggregate, addresses }) {
|
||||
return (
|
||||
<div className="w-full lg:w-[59%]">
|
||||
<div className="hidden lg:flex">
|
||||
@@ -14,7 +14,7 @@ function DetailDoctor({ doctor, comments, rateAggregate }) {
|
||||
<Title doctor={doctor} />
|
||||
<Link />
|
||||
<AboutDcotor doctor={doctor} />
|
||||
<Locations doctor={doctor} />
|
||||
<Locations addresses={addresses} />
|
||||
<Comments
|
||||
doctor={doctor}
|
||||
comments={comments}
|
||||
|
||||
@@ -5,7 +5,7 @@ import DetailDoctor from "./detailDoctor";
|
||||
import Share from "./detailDoctor/Share";
|
||||
import CustomLoading from "@/app/component/loading/Custom";
|
||||
|
||||
function DoctorPage({ doctor, comments, rateAggregate, slug }) {
|
||||
function DoctorPage({ doctor, comments, rateAggregate, addresses, slug }) {
|
||||
return (
|
||||
<div className="pt-[92px] sm:pt-[120px] mt:pt-[148px] lg:pt-[176px] mt-[px] padding-responsive">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -38,6 +38,7 @@ function DoctorPage({ doctor, comments, rateAggregate, slug }) {
|
||||
doctor={doctor}
|
||||
comments={comments}
|
||||
rateAggregate={rateAggregate}
|
||||
addresses={addresses}
|
||||
/>
|
||||
<AppointmentList doctor={doctor} doctorSlug={slug} />
|
||||
</div>
|
||||
|
||||
@@ -79,6 +79,7 @@ export const request = {
|
||||
getDoctorClaimInfo: (uuid) => api.get(`api/v1/doctor/${uuid}/claim-info`),
|
||||
postDoctorClaim: (uuid, data) =>
|
||||
api.post(`api/v1/doctor/${uuid}/claim`, data, { requireAuth: true }),
|
||||
deleteDoctor: (uuid) => api.delete(`api/v1/doctor/${uuid}`, { requireAuth: true }),
|
||||
getDoctorRate: (uuid) => api.get(`api/v1/rate/${uuid}`),
|
||||
getRateEligibility: (uuid) =>
|
||||
api.get(`api/v1/rate/${uuid}/eligibility`, { requireAuth: true }),
|
||||
|
||||
Reference in New Issue
Block a user