From 08107d6ef249fb6ced8805a7a6e99279a118e2c2 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 15 Jun 2026 18:07:10 +0330 Subject: [PATCH] feat(appointment): real for-another-patient form and booking payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the mock for-another button with a real toggle: switching keeps the user's own data, clears the form to editable patient fields (phone becomes an input, adds علت مراجعه), and can switch back. SubmitData now sends for_self plus patient_* only when booking for someone else, skips the self-profile PATCH/POST in that case, stores the booking expires_at, and surfaces a clearer 409 message. Thread appointmentExpiresAt through the wizard to the payment step. Co-Authored-By: Claude Opus 4.8 --- .claude/prompt/booking-lock-patient-flow.md | 160 ++++++++++++++++++++ components/appointment/Container.js | 12 +- components/appointment/detail/Form.js | 19 ++- components/appointment/detail/SubmitData.js | 19 ++- components/appointment/detail/index.js | 53 ++++--- components/appointment/index.js | 3 + 6 files changed, 238 insertions(+), 28 deletions(-) create mode 100644 .claude/prompt/booking-lock-patient-flow.md diff --git a/.claude/prompt/booking-lock-patient-flow.md b/.claude/prompt/booking-lock-patient-flow.md new file mode 100644 index 0000000..99b490a --- /dev/null +++ b/.claude/prompt/booking-lock-patient-flow.md @@ -0,0 +1,160 @@ +# فلوی نوبت‌گیری: قفل واقعی ۱۵دقیقه‌ای، فرم «نوبت برای شخص دیگر»، و UX وضعیت‌محور + +## پروژه + +`nobat724_front` — سایت عمومی. **بعد از پرامپت backend اجرا شود.** + +> **Cross-repo:** وابسته به اصلاحات backend در +> `clinicpro/.claude/prompt/appointment-lock-patient-confirm.md` +> (فیلدهای `patient_*` و `for_self` در `POST /api/v1/appointment`، `expires_at` در پاسخ، تأیید پس از پرداخت). اگر آن‌ها هنوز نیستند، **اول backend را اجرا کن.** + +## زمینه + +صفحه‌ی `/appointment/[doctorId]` یک wizard چندمرحله‌ای است (`components/appointment/`): انتخاب تاریخ/ساعت → لاگین OTP → تکمیل اطلاعات (Detail) → پرداخت (Paying). در اصلاحات قبلی فلو به API واقعی وصل شد (ساخت نوبت در `SubmitData.js`، پرداخت در `paying/index.js`). اما: + +- **تایمر پرداخت قلابی است:** `paying/index.js` یک `timeLeft = 600` (۱۰ دقیقه) شمارش معکوس محلی دارد که به قفل واقعی backend وصل نیست. باید ۱۵ دقیقه و بر مبنای `expires_at` واقعی نوبت باشد. +- **«نوبت برای شخص دیگر» فقط mock بود:** در `detail/index.js` دکمه‌ی «دریافت نوبت برای فرد دیگر» داده‌ی ثابت ست می‌کند و فیلدهای واقعی بیمار (نام، موبایل، جنسیت، کد ملی، علت) را نمی‌گیرد و به backend نمی‌فرستد. +- **بدنه‌ی ساخت نوبت فیلد بیمار/`for_self` ندارد.** + +## مشکل / هدف + +۱. فرم «نوبت برای شخص دیگر» با فیلدهای واقعی بیمار، و ارسال `patient_*` + `for_self` در ساخت نوبت. +۲. تایمر پرداخت بر مبنای `expires_at` واقعی نوبت (۱۵ دقیقه)؛ اتمام تایمر → نوبت منقضی، پیام مناسب، بازگشت به انتخاب ساعت. +۳. UX وضعیت‌محور: پیام‌های واضح برای رزرو هم‌زمان (۴۰۹)، انقضا، و موفقیت. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `components/appointment/index.js` | state کل wizard (`data`, `isForAnother`, `selectedSlot`, `appointmentId`) | +| `components/appointment/detail/index.js` | فرم Detail + دکمه‌ی «برای فرد دیگر» (الان mock) | +| `components/appointment/detail/Form.js` | رندر فیلدها | +| `components/appointment/detail/SubmitData.js` | `request.postAppointment` — بدنه باید `patient_*`/`for_self` بگیرد و `expires_at` را نگه دارد | +| `components/appointment/paying/index.js` | تایمر و پرداخت — تایمر باید از `expires_at` بیاید | +| `services/response.js` | `postAppointment` (بدنه از caller) | + +## وضعیت فعلی (کد واقعی) + +### `detail/index.js` — دکمه‌ی mock «برای فرد دیگر» + +```jsx +{!isForAnother && ( + +)} +``` + +### `SubmitData.js` — بدنه‌ی نوبت بدون بیمار + +```js +const appointmentPayload = { + doctor_uuid: doctor.uuid, + slot_start: selectedSlot.start, + slot_end: selectedSlot.end, + note: "", +}; +const res = await request.postAppointment(appointmentPayload); +const appointmentUuid = res?.data?.uuid; +if (appointmentUuid) setAppointmentId(appointmentUuid); +``` + +### `paying/index.js` — تایمر محلی ۶۰۰ ثانیه (قلابی) + +```js +const [timeLeft, setTimeLeft] = useState(600); // 10 minutes +useEffect(() => { + if (timeLeft === 0) return; + const timer = setInterval(() => setTimeLeft((p) => p - 1), 1000); + return () => clearInterval(timer); +}, [timeLeft]); +``` + +## قرارداد backend (بعد از اجرای پرامپت همتا) + +`POST /api/v1/appointment` بدنه: +```json +{ + "doctor_uuid": "...", "slot_start": 1781933400, "slot_end": 1781934600, + "for_self": true, + "patient_name": "...", "patient_mobile": "09...", "patient_national_code": "...", + "patient_gender": "male", "patient_reason": "...", "note": "" +} +``` +پاسخ `201`: `{ success, data: { uuid, status:"pending", expires_at: 1781934300, patient_name, ... } }` +خطاها: `409` (اسلات هم‌زمان رزرو شد)، `422` (فیلد بیمار ناقص وقتی for_self=false). + +> interceptor یک‌بار باز می‌کند → داده در `res.data`. `expires_at` Unix ثانیه است. + +## وظایف + +### ۱. فرم «نوبت برای شخص دیگر» (`detail/index.js` + `Form.js`) + +- دکمه را به یک toggle واقعی تبدیل کن: با `isForAnother=true` فیلدهای بیمار خالی و قابل‌ویرایش شوند (نه داده‌ی mock). +- فیلدهای بیمار: نام، نام خانوادگی، شماره موبایل (هر دو اجباری وقتی برای دیگری)، جنسیت (اختیاری)، کد ملی (اختیاری)، علت مراجعه (اختیاری، textarea). +- وقتی «برای خودم»: همان رفتار فعلی (اطلاعات پروفایل، قابل‌ویرایش). +- اعتبارسنجی حداقلی سمت کلاینت (نام و موبایل بیمار اجباری در حالت «برای دیگری»)؛ خطای فیلد را با همان الگوی `errors` موجود نشان بده. + +### ۲. ارسال `patient_*` و `for_self` در `SubmitData.js` + +- بدنه را گسترش بده: + +```js +const appointmentPayload = { + doctor_uuid: doctor.uuid, + slot_start: selectedSlot.start, + slot_end: selectedSlot.end, + for_self: !isForAnother, + note: data?.patient_reason?.value || "", + ...(isForAnother ? { + patient_name: data?.name?.value, + patient_mobile: data?.phone?.value, + patient_national_code: data?.national_code?.value || "", + patient_gender: data?.gender?.value?.id || data?.gender?.value || "", + patient_reason: data?.patient_reason?.value || "", + } : {}), +}; +``` + +- `isForAnother` را به `SubmitData` پاس بده (الان در `Container`/`Detail` هست). +- `expires_at` پاسخ را نگه‌دار و به مرحله‌ی پرداخت پاس بده (state جدید `appointmentExpiresAt` در `components/appointment/index.js`). +- خطای `409`: پیام «این زمان همین لحظه توسط کاربر دیگری رزرو شد» + بازگشت به مرحله‌ی انتخاب ساعت (`setStep(0)`) و تازه‌سازی اسلات‌ها. +- خطای `422`: پیام‌های فیلد بیمار. + +### ۳. تایمر پرداخت بر مبنای `expires_at` (`paying/index.js`) + +- به‌جای `useState(600)`، باقیمانده را از `expiresAt` (prop) حساب کن: + +```js +const computeLeft = () => Math.max(0, Math.floor((expiresAt * 1000 - Date.now()) / 1000)); +const [timeLeft, setTimeLeft] = useState(computeLeft); +useEffect(() => { + const t = setInterval(() => setTimeLeft(computeLeft()), 1000); + return () => clearInterval(t); +}, [expiresAt]); +``` + +- وقتی `timeLeft === 0`: دکمه‌ی پرداخت غیرفعال، پیام «مهلت پرداخت تمام شد؛ لطفاً دوباره زمان نوبت را انتخاب کنید»، و دکمه‌ای برای بازگشت به مرحله‌ی ۰. +- اگر `expiresAt` نبود (سازگاری)، fallback به ۹۰۰ ثانیه از زمان mount. + +### ۴. UX نهایی + +- در مرحله‌ی پرداخت، نام بیمار و زمان نوبت را نمایش بده (از همان داده‌ی نوبت). +- بعد از پرداخت موفق (بازگشت از درگاه به `/payment/result` یا step success)، پیام تأیید + اشاره به ارسال SMS. + +## نکات مهم + +- **وابستگی cross-repo:** بدون فیلدهای `patient_*`/`for_self` و `expires_at` در backend این کار کامل نیست. اگر نبود متوقف شو. +- **پرداخت‌کننده ≠ بیمار:** کاربر لاگین‌شده پرداخت‌کننده است (از کوکی/توکن، خودکار)؛ فیلدهای بیمار جدا ارسال می‌شوند. این تفکیک را در UI هم روشن نشان بده. +- **زمان‌ها Unix ثانیه:** `expires_at * 1000` برای مقایسه با `Date.now()`. +- **عدم رگرسیون:** فلوی OTP (step 1/2)، تقویم دوماهه، و گرید ساعت دست‌نخورده بمانند. منطق فعلی `SubmitData` برای PATCH/POST پروفایلِ کاربرِ «برای خودم» را حفظ کن (اطلاعات خودِ کاربر هنوز در پروفایلش ذخیره می‌شود؛ ولی فیلدهای بیمارِ نوبت جداگانه به نوبت می‌روند). +- **RTL/Jalali/multi-domain** حفظ شوند؛ فونت/کتابخانه‌ی جدید اضافه نکن. +- **تست:** `npm run build`؛ سپس دستی: «برای دیگری» با نام/موبایل بیمار → نوبت ساخته شود؛ تایمر از ~۱۵:۰۰ بشمارد؛ رزرو هم‌زمان روی یک اسلات → پیام ۴۰۹. سپس commit با پیام توصیفی برای هر قابلیت. diff --git a/components/appointment/Container.js b/components/appointment/Container.js index f2e1ab7..b5f3358 100644 --- a/components/appointment/Container.js +++ b/components/appointment/Container.js @@ -36,6 +36,8 @@ function Container({ setSelectedDate, appointmentId, setAppointmentId, + appointmentExpiresAt, + setAppointmentExpiresAt, }) { const elements = [ , + , - , , , ]; diff --git a/components/appointment/detail/Form.js b/components/appointment/detail/Form.js index 9b988bf..518d9f4 100644 --- a/components/appointment/detail/Form.js +++ b/components/appointment/detail/Form.js @@ -24,11 +24,15 @@ function Form({ changeData, insurance, supplementaryInsurance, data, isForAnothe className="grid mt-[12px] sm:mt-[14px] md:mt-[17px] lg:mt-[20px] items-start justify-center gap-x-[24px] gap-y-[12px] md:gap-y-[14px] lg:gap-y-[16px] grid-cols-1 sm:grid-cols-2" > - -
- {data?.phone?.value} - تایید شده -
+ + {isForAnother ? ( + + ) : ( +
+ {data?.phone?.value} + تایید شده +
+ )}
+ {isForAnother && ( + + + + )} diff --git a/components/appointment/detail/SubmitData.js b/components/appointment/detail/SubmitData.js index 94a6221..4bd4977 100644 --- a/components/appointment/detail/SubmitData.js +++ b/components/appointment/detail/SubmitData.js @@ -5,7 +5,7 @@ import ArrowLeftB from "@/components/icons/ArrowLeftB"; import { request } from "@/services/response"; import Cookies from "js-cookie"; -function SubmitData({ setStep, data, prevData, setErrors, doctor, selectedSlot, selectedDate, setAppointmentId }) { +function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt }) { const [loading, setLoading] = useState(false); const newStep = () => setStep((prev) => prev + 1); @@ -41,7 +41,7 @@ function SubmitData({ setStep, data, prevData, setErrors, doctor, selectedSlot, setLoading(true); try { - if (isChanged) { + if (!isForAnother && isChanged) { const { basic_insurance, supplementary_insurance, name, family, national_code, gender, insurance_id, uuid } = data; let payload = {}; @@ -101,13 +101,24 @@ function SubmitData({ setStep, data, prevData, setErrors, doctor, selectedSlot, doctor_uuid: doctor.uuid, slot_start: selectedSlot.start, slot_end: selectedSlot.end, - note: "", + for_self: !isForAnother, + note: data?.patient_reason?.value || "", + ...(isForAnother + ? { + patient_name: [data?.name?.value, data?.family?.value].filter(Boolean).join(" ").trim(), + patient_mobile: data?.phone?.value, + patient_national_code: data?.national_code?.value || "", + patient_gender: data?.gender?.value?.id || data?.gender?.value || "", + patient_reason: data?.patient_reason?.value || "", + } + : {}), }; const res = await request.postAppointment(appointmentPayload); const appointmentUuid = res?.data?.uuid; if (appointmentUuid) { setAppointmentId(appointmentUuid); + setAppointmentExpiresAt?.(res?.data?.expires_at ?? null); } } @@ -116,7 +127,7 @@ function SubmitData({ setStep, data, prevData, setErrors, doctor, selectedSlot, } catch (error) { setLoading(false); if (error?.response?.status === 409) { - alert("این نوبت پیش‌تر رزرو شده است. لطفاً ساعت دیگری انتخاب کنید."); + alert("این زمان همین لحظه توسط کاربر دیگری رزرو شد. لطفاً ساعت دیگری انتخاب کنید."); setStep(0); return; } diff --git a/components/appointment/detail/index.js b/components/appointment/detail/index.js index e62b68c..de051d6 100644 --- a/components/appointment/detail/index.js +++ b/components/appointment/detail/index.js @@ -19,10 +19,12 @@ function Detail({ selectedSlot, selectedDate, setAppointmentId, + setAppointmentExpiresAt, }) { const [insurance, setInsurance] = useState(); const [supplementaryInsurance, setSupplementaryInsurance] = useState(); const [errors, setErrors] = useState({}); + const [selfData, setSelfData] = useState(null); const changeData = (value, type, name) => setData({ @@ -33,6 +35,28 @@ function Detail({ }, }); + const emptyPatientData = () => ({ + phone: { value: "", isEdit: true }, + national_code: { value: "", isEdit: true }, + name: { value: "", isEdit: true }, + family: { value: "", isEdit: true }, + gender: { value: "", isEdit: true }, + patient_reason: { value: "", isEdit: true }, + }); + + const switchToAnother = () => { + setSelfData(data); + setIsForAnother(true); + setErrors({}); + setData(emptyPatientData()); + }; + + const switchToSelf = () => { + setIsForAnother(false); + setErrors({}); + if (selfData) setData(selfData); + }; + useEffect(() => { // دریافت لیست بیمه پایه request.getInsuranceType().then((res) => { @@ -78,33 +102,26 @@ function Detail({ isForAnother={isForAnother} errors={errors} /> - {!isForAnother && ( - - )} + diff --git a/components/appointment/index.js b/components/appointment/index.js index eaa362f..267a622 100644 --- a/components/appointment/index.js +++ b/components/appointment/index.js @@ -56,6 +56,7 @@ function AppointmentPage({ doctor, disabledDates, matchedCity }) { const [selectedSlot, setSelectedSlot] = useState(null); const [selectedDate, setSelectedDate] = useState(null); const [appointmentId, setAppointmentId] = useState(null); + const [appointmentExpiresAt, setAppointmentExpiresAt] = useState(null); useEffect(() => { const fetchUserData = async () => { @@ -183,6 +184,8 @@ function AppointmentPage({ doctor, disabledDates, matchedCity }) { setSelectedDate={setSelectedDate} appointmentId={appointmentId} setAppointmentId={setAppointmentId} + appointmentExpiresAt={appointmentExpiresAt} + setAppointmentExpiresAt={setAppointmentExpiresAt} /> ); }