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:
hamed
2026-07-11 15:04:28 +03:30
co-authored by Claude Opus 4.8
parent d8ab26b4b7
commit 4fed9a8c57
7 changed files with 204 additions and 11 deletions
+76 -5
View File
@@ -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>
+2 -2
View File
@@ -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}
+2 -1
View File
@@ -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>