Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c21938eb | ||
|
|
2fb17d778a | ||
|
|
519de26839 | ||
|
|
4c912dc6c1 | ||
|
|
15edc628e4 | ||
|
|
ae958eee73 | ||
|
|
d58eec1dba | ||
|
|
f3659f827e |
@@ -1,422 +0,0 @@
|
|||||||
# نمایش چندتخصصی پزشک در کارت، فیلترها و پوستر
|
|
||||||
|
|
||||||
## پروژه
|
|
||||||
|
|
||||||
`nobat724_front` — سایت عمومی نوبتدهی.
|
|
||||||
|
|
||||||
**cross-repo.** پرامپت همتا: `clinicpro/.claude/prompt/doctor-multi-specialty-search.md`.
|
|
||||||
**آن را اول اجرا کن.** وظیفهٔ ۳ اینجا به کلید `parent_id` در `specialties[]` پاسخ
|
|
||||||
`GET /api/v1/doctors` نیاز دارد که همانجا اضافه میشود.
|
|
||||||
|
|
||||||
## زمینه
|
|
||||||
|
|
||||||
هر پزشک چند تخصص دارد و ساختار دو سطحی والد/فرزند است. نمونهٔ واقعی — دکتر محمدباقر جهانتاب
|
|
||||||
شش تخصص دارد:
|
|
||||||
|
|
||||||
```
|
|
||||||
13 جراحی عمومی (والد)
|
|
||||||
14 جراحی پلاستیک و زیبایی
|
|
||||||
167 جراحی لاپاراسکوپی
|
|
||||||
168 جراح تیروئید
|
|
||||||
169 جراح گوارش
|
|
||||||
170 جراحی سرطانها
|
|
||||||
```
|
|
||||||
|
|
||||||
## مشکل / هدف
|
|
||||||
|
|
||||||
چهار چیز در سایت عمومی میشکند.
|
|
||||||
|
|
||||||
### ۱. `data/specialties.json` کهنه است — ریشهٔ بقیهٔ مشکلات
|
|
||||||
|
|
||||||
فایل ثابت است و با دیتابیس همگام نیست:
|
|
||||||
|
|
||||||
- دیتابیس ۹۹ تخصص دارد، فایل ۹۴ تا.
|
|
||||||
- پنج فرزندِ `جراحی عمومی` با شناسههای ۱۶۷ تا ۱۷۱ در فایل **نیستند** — دقیقاً همانهایی که
|
|
||||||
این پزشک دارد.
|
|
||||||
|
|
||||||
این فایل در ۹ جا استفاده میشود: `app/sitemap.js`، `app/specialties/[slug]/page.js`،
|
|
||||||
`app/doctor/[slug]/page.js`، `components/home/search/Fields.js`، `components/specialties/index.js`،
|
|
||||||
`components/specialties/list/ItemSpecialties.js`، `components/doctor/index.js`،
|
|
||||||
`components/clinics/index.js`، `helper/index.js`.
|
|
||||||
|
|
||||||
نتیجه: `/specialties/جراح-گوارش` برابر ۴۰۴ است، لینک breadcrumb صفحهٔ پزشک میشکند، و
|
|
||||||
تخصص در سایتمپ نیست. تا این حل نشود، ساختن UI والد/فرزند ممکن نیست.
|
|
||||||
|
|
||||||
### ۲. انتخاب گروه، بیصدا به اولین زیرتخصص محدود میشود
|
|
||||||
|
|
||||||
کاربر `جراحی عمومی` را میزند و بدون اینکه بداند، فیلتر روی `جراحی پلاستیک و زیبایی` مینشیند.
|
|
||||||
هیچ گزینهای برای «کل گروه» وجود ندارد.
|
|
||||||
|
|
||||||
### ۳. کارت پزشک همهٔ تخصصها را پشتسرهم چاپ میکند
|
|
||||||
|
|
||||||
در موبایل ارتفاع کارت باد میکند و شبکه بههم میریزد.
|
|
||||||
|
|
||||||
### ۴. پوستر سرریز میکند
|
|
||||||
|
|
||||||
چهار چیپ اول با نامهای بلند، به سه ردیف میروند، بخش hero بلند میشود و بخشهای پایین از
|
|
||||||
کادر ثابت `1080×1350` با `overflow-hidden` بیرون میزنند.
|
|
||||||
|
|
||||||
## معیار پذیرش
|
|
||||||
|
|
||||||
- ✅ موفق: بعد از `npm run build`، فایل `data/specialties.json` هر ۹۹ تخصص فعال را با
|
|
||||||
`parent_id` و `slug` دارد و `/specialties/جراح-گوارش` صفحه میدهد نه ۴۰۴.
|
|
||||||
- ✅ موفق: در مودال فیلترها، انتخاب گروه `جراحی عمومی` گزینهٔ پیشفرض
|
|
||||||
«همه تخصصهای جراحی عمومی» را میگذارد و درخواست با `specialty_id=13` میرود.
|
|
||||||
- ✅ موفق: کارت دکتر جهانتاب در موبایل و دسکتاپ فقط `جراحی عمومی +5` نشان میدهد و ارتفاعش با
|
|
||||||
کارت پزشک تکتخصصی یکی است.
|
|
||||||
- ✅ موفق: کلیک روی `+5` فهرست کامل شش تخصص را در Popover نشان میدهد و **صفحهٔ پزشک را باز نمیکند**.
|
|
||||||
- ✅ موفق: پوستر همان پزشک، هیچ محتوایی بیرون از کادر ۱۳۵۰ ندارد و تخصصها روی هم نمیافتند.
|
|
||||||
- ❌ خطا: پزشک بدون هیچ تخصص → کارت بدون بخش تخصص و بدون `+0`، نه `undefined` و نه کرش.
|
|
||||||
- ❌ خطا: اسکریپت همگامسازی وقتی API در دسترس نیست → build با پیام روشن شکست بخورد و فایل
|
|
||||||
موجود را با آرایهٔ خالی بازنویسی **نکند**.
|
|
||||||
- ⚠️ مرزی: پزشک با دقیقاً یک تخصص → فقط نام، بدون چیپ `+N`.
|
|
||||||
- ⚠️ مرزی: پزشک با دو تخصص ریشهٔ متفاوت (مثلاً `جراحی عمومی` و `داخلی`) → اولین ریشه نمایش،
|
|
||||||
بقیه در `+N`.
|
|
||||||
- ⚠️ مرزی: پزشکی که فقط زیرتخصص دارد و هیچ ریشهای ندارد → اولین تخصص آرایه نمایش داده شود.
|
|
||||||
- ⚠️ مرزی: پوستر پزشکی با نام تخصص خیلی بلند → بریدن روی مرز کلمه، نه وسط کلمه.
|
|
||||||
|
|
||||||
## فایلهای مرتبط
|
|
||||||
|
|
||||||
| فایل | نقش |
|
|
||||||
|------|-----|
|
|
||||||
| `scripts/sync-specialties.mjs` | **جدید** — ساخت `data/specialties.json` از API |
|
|
||||||
| `package.json` | قلاب `prebuild` |
|
|
||||||
| `lib/specialtyDisplay.js` | **جدید** — قاعدهٔ «تخصص اصلی» مشترک بین کارت و پوستر |
|
|
||||||
| `helper/index.js` | `filterList()` خط ۲۷۰ |
|
|
||||||
| `components/doctors/modal/Content.js` | انتخاب اجباری اولین فرزند |
|
|
||||||
| `components/doctors/modal/form/index.js` | دو `CateSelector` |
|
|
||||||
| `app/component/ItemDoctor.js` | کارت پزشک |
|
|
||||||
| `app/component/PopoverDate.js` | الگوی موجود Popover — تقلید کن، از نو ننویس |
|
|
||||||
| `components/doctor/poster/index.js` | پوستر تیره |
|
|
||||||
| `components/doctor/poster/PosterLight.js` | پوستر روشن — همان باگ |
|
|
||||||
| `components/doctors/search/SearchField.js` | placeholder کادر جستجو |
|
|
||||||
|
|
||||||
## وضعیت فعلی
|
|
||||||
|
|
||||||
### انتخاب اجباری اولین زیرتخصص
|
|
||||||
|
|
||||||
```js
|
|
||||||
// components/doctors/modal/Content.js:5
|
|
||||||
const changeSpecialty = (name, value) => {
|
|
||||||
const newFilter = { ...filter, [name]: value };
|
|
||||||
|
|
||||||
if (name === "category") {
|
|
||||||
if (value) {
|
|
||||||
const filtered = specialties
|
|
||||||
.filter((item) => item.parent_id)
|
|
||||||
.filter((item) => String(item.parent_id) === String(value.id));
|
|
||||||
newFilter.specialty = filtered[0] || null;
|
|
||||||
} else {
|
|
||||||
newFilter.specialty = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setFilter(newFilter);
|
|
||||||
setDataInURL(newFilter);
|
|
||||||
return newFilter;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
و ساخت پارامتر، `specialty` را بر `category` ترجیح میدهد:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// helper/index.js:482
|
|
||||||
if (newFilter.specialty?.id) {
|
|
||||||
params.specialty_id = newFilter.specialty.id;
|
|
||||||
} else if (newFilter.category?.id) {
|
|
||||||
params.specialty_id = newFilter.category.id;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```js
|
|
||||||
// helper/index.js:270
|
|
||||||
export const filterList = (data) => {
|
|
||||||
const parentList = specialties.filter((item) => !item.parent_id);
|
|
||||||
const childrenList = specialties.filter((item) => item.parent_id);
|
|
||||||
|
|
||||||
const filteredChildrenList =
|
|
||||||
data && data.category
|
|
||||||
? childrenList.filter(
|
|
||||||
(item) => String(item.parent_id) === String(data.category.id)
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
parentList: parentList,
|
|
||||||
childrenList: filteredChildrenList,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### کارت — همه را پشتسرهم چاپ میکند
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// app/component/ItemDoctor.js:46
|
|
||||||
<TextLoading loading={loading} width={120} height={15}>
|
|
||||||
<p className="text-[#616161] text-[14px] font-normal">
|
|
||||||
تخصص:
|
|
||||||
{doctor?.specialties?.map(
|
|
||||||
(item, idx) =>
|
|
||||||
`${item.name} ${doctor.specialties.length === idx + 1 ? "" : "|"} `
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</TextLoading>
|
|
||||||
```
|
|
||||||
|
|
||||||
### پوستر — برش کور روی عدد ۴
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// components/doctor/poster/index.js:44 (و PosterLight.js:24 دقیقاً همین)
|
|
||||||
const specialties = (data?.specialties ?? []).filter((s) => s?.name).slice(0, 4);
|
|
||||||
```
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// components/doctor/poster/index.js:132
|
|
||||||
<div className="flex flex-wrap gap-[10px] mt-[16px]">
|
|
||||||
{specialties.map((s, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="flex items-center rounded-full bg-[#F59E0B]/18 border border-[#F59E0B]/40 px-[18px] py-[9px]"
|
|
||||||
>
|
|
||||||
<span className="text-[#FCD9A0] text-[22px] font-semibold leading-none">
|
|
||||||
{s.name}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
کادر ثابت است، پس هر ردیف اضافه محتوای پایین را بیرون میاندازد:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// components/doctor/poster/index.js:81
|
|
||||||
<div
|
|
||||||
dir="rtl"
|
|
||||||
className="relative w-[1080px] h-[1350px] overflow-hidden flex flex-col p-[64px] text-right"
|
|
||||||
```
|
|
||||||
|
|
||||||
## وظایف
|
|
||||||
|
|
||||||
### ۱. اسکریپت همگامسازی `data/specialties.json`
|
|
||||||
|
|
||||||
فایل جدید `scripts/sync-specialties.mjs`.
|
|
||||||
|
|
||||||
منبع: `GET ${NEXT_PUBLIC_API_URL}/api/v1/specialties` — **عمومی است و توکن نمیخواهد**.
|
|
||||||
بدون پارامتر `parent_id` همهٔ تخصصهای فعال را میدهد. پاسخ دولایه است:
|
|
||||||
`{ success, data: { data: [...] } }`.
|
|
||||||
|
|
||||||
هر آیتم دقیقاً شکل فایل فعلی را دارد: `id` و `uuid` و `name` و `slug` و `status` و `weight`
|
|
||||||
و `parent_id`.
|
|
||||||
|
|
||||||
```js
|
|
||||||
// scripts/sync-specialties.mjs
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
|
||||||
const OUT = new URL("../data/specialties.json", import.meta.url);
|
|
||||||
|
|
||||||
const res = await fetch(`${API_URL}/api/v1/specialties`);
|
|
||||||
if (!res.ok) throw new Error(`specialties sync failed: HTTP ${res.status}`);
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
const items = json?.data?.data ?? [];
|
|
||||||
|
|
||||||
// آرایهٔ خالی یعنی چیزی غلط است — فایل موجود را با آن بازنویسی نکن.
|
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
|
||||||
throw new Error("specialties sync returned an empty list; refusing to overwrite");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
قبل از نوشتن، خروجی را با ترتیب پایدار مرتب کن (بر اساس `id`) تا diff فایل نویزی نشود،
|
|
||||||
و با دو فاصله و `\n` انتهایی بنویس تا با شکل فعلی فایل بخواند.
|
|
||||||
|
|
||||||
در `package.json` قلاب بزن:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"prebuild": "node scripts/sync-specialties.mjs",
|
|
||||||
```
|
|
||||||
|
|
||||||
`dev` را وصل نکن — توسعهٔ آفلاین نباید به API گره بخورد.
|
|
||||||
|
|
||||||
**نحوه تست:**
|
|
||||||
```bash
|
|
||||||
cd nobat724_front
|
|
||||||
node scripts/sync-specialties.mjs
|
|
||||||
python3 -c "import json;d=json.load(open('data/specialties.json'));print(len(d), [x['slug'] for x in d if x.get('parent_id')==13])"
|
|
||||||
```
|
|
||||||
باید ۹۹ و شامل `جراح-گوارش` باشد. سپس با `NEXT_PUBLIC_API_URL` غلط اجرا کن و مطمئن شو
|
|
||||||
خطا میدهد و فایل دستنخورده میماند.
|
|
||||||
بعد `npm run build` و باز کردن `/specialties/جراح-گوارش`.
|
|
||||||
|
|
||||||
### ۲. گزینهٔ «همه تخصصهای X»
|
|
||||||
|
|
||||||
`filterList` در `helper/index.js` یک آیتمِ «همه» به ابتدای `childrenList` اضافه کند که
|
|
||||||
شناسهاش همان شناسهٔ والد است:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const filteredChildrenList =
|
|
||||||
data && data.category
|
|
||||||
? [
|
|
||||||
// «همه» یعنی فیلتر روی خودِ گروه؛ بکاند specialty_id را به نوادگان گسترش میدهد.
|
|
||||||
{ id: data.category.id, name: `همه تخصصهای ${data.category.name}`, parent_id: null },
|
|
||||||
...childrenList.filter(
|
|
||||||
(item) => String(item.parent_id) === String(data.category.id)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
```
|
|
||||||
|
|
||||||
و در `Content.js` بهجای `filtered[0]`، همان آیتم «همه» انتخاب شود:
|
|
||||||
|
|
||||||
```js
|
|
||||||
if (name === "category") {
|
|
||||||
// پیشفرض «کل گروه» است، نه اولین زیرتخصص. انتخاب بیصدای اولین فرزند،
|
|
||||||
// جستجوی کاربر را بدون اطلاعش تنگ میکرد.
|
|
||||||
newFilter.specialty = value
|
|
||||||
? { id: value.id, name: `همه تخصصهای ${value.name}` }
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`QueryForDoctorsReq` را دست نزن — چون شناسهٔ «همه» همان شناسهٔ والد است، همان مسیر فعلی
|
|
||||||
`specialty_id` را درست میفرستد.
|
|
||||||
|
|
||||||
**نحوه تست:** unit test در `helper/specialtyFilter.test.js` برای `filterList` —
|
|
||||||
با `category` برابر `جراحی عمومی` اولین آیتم `childrenList` باید `id` والد و عنوان
|
|
||||||
«همه تخصصهای جراحی عمومی» داشته باشد؛ بدون `category` آرایه خالی بماند.
|
|
||||||
سپس دستی: مودال فیلترها → گروه `جراحی عمومی` → در تب شبکه ببین `specialty_id=13` میرود
|
|
||||||
و دکتر جهانتاب در نتایج هست.
|
|
||||||
|
|
||||||
### ۳. قاعدهٔ مشترک «تخصص اصلی»
|
|
||||||
|
|
||||||
فایل جدید `lib/specialtyDisplay.js`. کارت و پوستر هر دو از این میخوانند تا قاعده دو جا
|
|
||||||
تکرار و واگرا نشود.
|
|
||||||
|
|
||||||
```js
|
|
||||||
/**
|
|
||||||
* تخصص «اصلی» و بقیه.
|
|
||||||
*
|
|
||||||
* ریشه (بدون parent_id) اصلی است چون عنوانی است که بیمار میشناسد و هنگام ذخیره در
|
|
||||||
* بکاند خودکار به پزشک اضافه میشود، پس تقریباً همیشه وجود دارد. اگر ریشهای نبود،
|
|
||||||
* اولین آیتم آرایه.
|
|
||||||
*/
|
|
||||||
export function splitSpecialties(list) {
|
|
||||||
const items = (list ?? []).filter((s) => s?.name);
|
|
||||||
if (items.length === 0) return { primary: null, rest: [] };
|
|
||||||
|
|
||||||
const primary = items.find((s) => !s.parent_id) ?? items[0];
|
|
||||||
|
|
||||||
return { primary, rest: items.filter((s) => s !== primary) };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**نحوه تست:** `lib/specialtyDisplay.test.js` — آرایهٔ خالی؛ تکتخصص؛ ریشه وسط آرایه؛
|
|
||||||
هیچ ریشهای نبودن؛ آیتمِ بدون `name` که باید حذف شود.
|
|
||||||
|
|
||||||
### ۴. کارت پزشک — تخصص اصلی و `+N`
|
|
||||||
|
|
||||||
در `app/component/ItemDoctor.js` بلوک `<p>` تخصص با این جایگزین شود:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
const { primary, rest } = splitSpecialties(doctor?.specialties);
|
|
||||||
```
|
|
||||||
|
|
||||||
نمایش: نام `primary`، و اگر `rest.length > 0` یک چیپ کوچک `+{rest.length}` کنارش.
|
|
||||||
کلیک روی چیپ، `Popover` باز کند با فهرست کامل (`primary` و `rest`).
|
|
||||||
|
|
||||||
الگوی `Popover` را از `app/component/PopoverDate.js` بردار، از صفر ننویس.
|
|
||||||
|
|
||||||
سه نکتهٔ اجباری:
|
|
||||||
|
|
||||||
- روی `onClick` چیپ حتماً `e.preventDefault()` و `e.stopPropagation()` بزن. کارت داخل
|
|
||||||
`Link` است و بدون این، کلیک صفحهٔ پزشک را باز میکند.
|
|
||||||
- چیپ باید `<button type="button">` باشد با `aria-label` روشن مثل
|
|
||||||
`نمایش ${rest.length} تخصص دیگر`، نه `<span>` با `onClick`.
|
|
||||||
- ارتفاع کارت نباید تغییر کند. نام `primary` در یک خط با `truncate` بماند.
|
|
||||||
|
|
||||||
استایل چیپ با توکنهای موجود همان فایل: `text-[12px]` و `rounded-[4px]` و
|
|
||||||
`bg-[#F8F8FF]` و `text-[#616161]` — همان چیزی که بلوک امتیاز و رضایت استفاده میکند.
|
|
||||||
رنگ یا کلاس تازه اضافه نکن.
|
|
||||||
|
|
||||||
**نحوه تست:** `npm run test` با یک تست کامپوننتی —
|
|
||||||
پزشک با ۶ تخصص: متن `جراحی عمومی` هست و `+5` هست؛ کلیک روی `+5` هر شش نام را نشان میدهد؛
|
|
||||||
پزشک با ۱ تخصص: هیچ `+` در DOM نیست؛ پزشک بدون تخصص: کرش نمیکند.
|
|
||||||
سپس دستی در موبایل روی `/doctors` — ارتفاع کارت جهانتاب با کارت کناری یکی باشد.
|
|
||||||
|
|
||||||
### ۵. پوستر — والد درشت، زیرتخصصها یک خط متنی
|
|
||||||
|
|
||||||
**هر دو فایل** `components/doctor/poster/index.js` و `components/doctor/poster/PosterLight.js`.
|
|
||||||
هر دو دقیقاً همان `slice(0, 4)` را دارند.
|
|
||||||
|
|
||||||
منطق پوستر عمداً با کارت فرق دارد: در چاپ نه کلیک هست نه Popover، پس `+N` بیمعناست.
|
|
||||||
|
|
||||||
در `lib/specialtyDisplay.js` یک تابع دوم بگذار:
|
|
||||||
|
|
||||||
```js
|
|
||||||
/**
|
|
||||||
* زیرتخصصها بهشکل یک خط متنی، بریده روی مرز کلمه با بودجهٔ کاراکتر.
|
|
||||||
* پوستر کادر ثابت دارد و overflow-hidden است؛ هر ردیف اضافه، بخشهای پایین را بیرون میاندازد.
|
|
||||||
*/
|
|
||||||
export function posterSpecialtyLine(rest, budget = 90) {
|
|
||||||
const names = rest.map((s) => s.name);
|
|
||||||
const shown = [];
|
|
||||||
let used = 0;
|
|
||||||
|
|
||||||
for (const name of names) {
|
|
||||||
const cost = name.length + (shown.length ? 3 : 0); // ' · '
|
|
||||||
if (used + cost > budget) break;
|
|
||||||
shown.push(name);
|
|
||||||
used += cost;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hidden = names.length - shown.length;
|
|
||||||
|
|
||||||
return { text: shown.join(" · "), hidden };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
در پوستر: `primary` همان چیپ درشت فعلی بماند (فقط یکی، نه چهارتا).
|
|
||||||
زیرش یک `<p>` با `text-[20px]` و رنگ کمرنگتر موجود (`#C7DBF2` در تیره، معادلش در روشن)
|
|
||||||
که `text` را نشان میدهد و اگر `hidden > 0` بود `و {hidden} تخصص دیگر` را به آن میچسباند.
|
|
||||||
|
|
||||||
روی ظرف زیرتخصصها `max-h` بگذار معادل دو خط، تا حتی اگر بودجه اشتباه تنظیم شد،
|
|
||||||
کادر سرریز نکند.
|
|
||||||
|
|
||||||
**نحوه تست:** تست واحد `posterSpecialtyLine` — بودجهٔ کوچک با نامهای بلند؛ آرایهٔ خالی
|
|
||||||
(`text` تهی و `hidden` صفر)؛ یک نام بلندتر از کل بودجه (نباید وسط کلمه بریده شود).
|
|
||||||
سپس دستی: پوستر دکتر جهانتاب را از صفحهٔ پزشک بساز و مطمئن شو در هر دو تم روشن و تیره
|
|
||||||
هیچ چیزی بیرون از کادر نیست.
|
|
||||||
|
|
||||||
### ۶. placeholder کادر جستجو
|
|
||||||
|
|
||||||
```js
|
|
||||||
// components/doctors/search/SearchField.js:58
|
|
||||||
placeholder="جستجوی نام پزشک ..."
|
|
||||||
```
|
|
||||||
|
|
||||||
به «جستجوی نام پزشک یا تخصص ...» تغییر کند. بکاند بعد از پرامپت همتا نام تخصص را هم میگردد
|
|
||||||
و placeholder فعلی دروغ میشود.
|
|
||||||
|
|
||||||
**نحوه تست:** تایپ `جراح گوارش` در کادر و دیدن دکتر جهانتاب در نتایج. این تست فقط بعد از
|
|
||||||
اجرای پرامپت بکاند معنا دارد.
|
|
||||||
|
|
||||||
## نکات مهم
|
|
||||||
|
|
||||||
- **ترتیب اجرا اجباری است.** وظیفهٔ ۳ و ۴ بدون `parent_id` در پاسخ API کار نمیکنند.
|
|
||||||
اگر بکاند هنوز اجرا نشده، `splitSpecialties` همیشه `items[0]` را برمیگرداند و
|
|
||||||
نتیجه ظاهراً درست ولی غیرقابلاتکا میشود.
|
|
||||||
- **وظیفهٔ ۱ پیشنیاز وظیفهٔ ۲ است.** بدون فایلِ بهروز، فرزندهای ۱۶۷ تا ۱۷۱ در
|
|
||||||
`filterList` نیستند و گزینهٔ «همه» روی گروهی مینشیند که فرزندانش را نمیبیند.
|
|
||||||
- **یک قاعده، یک جا.** انتخاب «تخصص اصلی» فقط در `lib/specialtyDisplay.js`. اگر در
|
|
||||||
`ItemDoctor` یا پوستر دوباره نوشته شود، فردا دو جا واگرا میشوند. این همان دلیل ساختن
|
|
||||||
فایل است، نه abstraction برای آینده.
|
|
||||||
- **کلیک داخل `Link`.** بدون `stopPropagation` روی چیپ `+N`، هر بار که کاربر تخصصها را
|
|
||||||
میبیند به صفحهٔ پزشک پرت میشود. این را حتماً تست کن.
|
|
||||||
- **پوستر دو فایل است.** `index.js` تیره و `PosterLight.js` روشن. اصلاح یکی و فراموشی
|
|
||||||
دیگری، باگ را نصفه رها میکند.
|
|
||||||
- **سئو.** `data/specialties.json` به `app/sitemap.js` هم خوراک میدهد. بعد از همگامسازی،
|
|
||||||
سایتمپ تخصصهای تازه را میگیرد — این مطلوب است، ولی مطمئن شو اسکریپت در شکست، فایل را
|
|
||||||
خالی نمیکند وگرنه سایتمپ کوچک میشود و صفحات از ایندکس میافتند.
|
|
||||||
- **استایل.** MUI v5 و Tailwind و RTL و فونت Vazir. کلاس یا رنگ تازه اضافه نکن؛ از همان
|
|
||||||
توکنهای موجود در همان فایل استفاده کن. راهحل با CSS موقت یا `!important` پذیرفته نیست.
|
|
||||||
- **صفحهٔ جزئیات پزشک عمداً خارج از محدوده است.** breadcrumb شکستهاش عارضهٔ فایل کهنه است و
|
|
||||||
با وظیفهٔ ۱ خودبهخود درست میشود. اگر بعد از وظیفهٔ ۱ باز هم شکسته بود، آیتم تازه به
|
|
||||||
todo اضافه کن و گزارش بده.
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
# رفع لینک `/doctor/undefined` و متمایزسازی صفحهٔ اصلی شهرها
|
|
||||||
|
|
||||||
## پروژه
|
|
||||||
|
|
||||||
`nobat724_front` — سایت عمومی. بدون تغییر بکاند.
|
|
||||||
|
|
||||||
سند همراه: `docs/seo/gsc-expected-exclusions.md` — آن خطاهای Search Console که **باگ نیستند**
|
|
||||||
و نباید «رفع» شوند. این پرامپت فقط دو موردِ واقعاً کدی را میسازد.
|
|
||||||
|
|
||||||
## زمینه
|
|
||||||
|
|
||||||
Search Console روی `behbahan-nobat.ir` و `yasuj-nobat.ir` شش دستهٔ مسئله نشان میدهد.
|
|
||||||
چهار دسته رفتار عمدی سیستماند و در سند بالا توضیح داده شدهاند. دو دسته باگ واقعیاند:
|
|
||||||
|
|
||||||
- **Not found (404)** — `https://behbahan-nobat.ir/doctor/undefined`، آخرین crawl ۳۰ ژوئیه
|
|
||||||
- **Duplicate, Google chose different canonical than user** — `https://behbahan-nobat.ir/`
|
|
||||||
|
|
||||||
## مشکل / هدف
|
|
||||||
|
|
||||||
### ۱. لینک `/doctor/undefined`
|
|
||||||
|
|
||||||
اسکلت بارگذاری صفحهٔ پزشکان، شش کارت با آبجکتِ بدون `uuid` رندر میکند. `ItemDoctor`
|
|
||||||
لینک را با `doctor?.uuid` میسازد، پس در HTML شش `<a href="/doctor/undefined">` مینشیند و
|
|
||||||
Googlebot همان را crawl میکند.
|
|
||||||
|
|
||||||
زنده تأیید شد:
|
|
||||||
|
|
||||||
```
|
|
||||||
curl -o /dev/null -w "%{http_code}" https://behbahan-nobat.ir/doctor/undefined
|
|
||||||
404
|
|
||||||
```
|
|
||||||
|
|
||||||
### ۲. صفحهٔ اصلی هر شهر تقریباً کپی بقیه است
|
|
||||||
|
|
||||||
canonical درست و self است — این را از خودِ سایت زنده گرفتم:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://behbahan-nobat.ir/ → <link rel="canonical" href="https://behbahan-nobat.ir">
|
|
||||||
https://yasuj-nobat.ir/ → <link rel="canonical" href="https://yasuj-nobat.ir">
|
|
||||||
```
|
|
||||||
|
|
||||||
پس گوگل canonical ما را **رد کرده**، نه اینکه ما اشتباه اعلام کرده باشیم. دلیلش اندازهگیری شد:
|
|
||||||
|
|
||||||
```
|
|
||||||
شباهت متنِ رندرشدهٔ دو صفحهٔ اصلی: ۹۹.۰٪
|
|
||||||
```
|
|
||||||
|
|
||||||
تنها چیزِ شهرمحورِ صفحهٔ اصلی، `site_name` و `slogan` از `data/city.json` است. بقیه —
|
|
||||||
بنر، کادر جستجو، «جستجوهای پرتکرار»، فوتر — روی همهٔ دامنهها یکسان است. تا محتوا
|
|
||||||
متمایز نشود هیچ تگی جلوی تجمیع را نمیگیرد؛ همین ریشهٔ بخش بزرگی از
|
|
||||||
`Discovered - currently not indexed` هم هست.
|
|
||||||
|
|
||||||
## معیار پذیرش
|
|
||||||
|
|
||||||
- ✅ موفق: در HTML صفحهٔ `/doctors` (چه در حالت اسکلت چه با داده) هیچ
|
|
||||||
`href="/doctor/undefined"` نباشد.
|
|
||||||
- ✅ موفق: صفحهٔ اصلی هر شهر یک بخش متنی و آماری دارد که با شهر دیگر فرق میکند —
|
|
||||||
شمار پزشک و کلینیک همان شهر، و تخصصهای پرتکرارِ همان شهر.
|
|
||||||
- ✅ موفق: شباهت متنِ رندرشدهٔ دو صفحهٔ اصلی به زیر ۸۵٪ برسد (سنجهٔ عینی، دستور پایین).
|
|
||||||
- ❌ خطا: اگر API پاسخ ندهد یا شهر پزشکی نداشته باشد، صفحهٔ اصلی نباید بشکند و نباید
|
|
||||||
عدد صفر یا «undefined» نشان دهد — بخش آمار حذف میشود و بقیهٔ صفحه سالم میماند.
|
|
||||||
- ❌ خطا: کارت اسکلت (بدون `uuid`) نباید اصلاً لینک باشد.
|
|
||||||
- ⚠️ مرزی: دامنهٔ ریشه `nobat724.com` شهر ندارد؛ متن و آمار شهری نباید آنجا رندر شود.
|
|
||||||
- ⚠️ مرزی: شهری که تازه اضافه شده و صفر پزشک دارد — پیام بیجایگزین نه، بلکه حذف بخش آمار.
|
|
||||||
- ⚠️ مرزی: `loading.js` روی مسیر `/doctors` است؛ اسکلتهای دیگری که `ItemDoctor` را با
|
|
||||||
دادهٔ ناقص رندر میکنند هم باید همین رفتار را بگیرند.
|
|
||||||
|
|
||||||
## فایلهای مرتبط
|
|
||||||
|
|
||||||
| فایل | نقش |
|
|
||||||
|------|-----|
|
|
||||||
| `app/component/ItemDoctor.js` | سازندهٔ لینک پزشک — تنها جای پروژه |
|
|
||||||
| `app/doctors/loading.js` | اسکلتی که آبجکت بدون `uuid` میدهد |
|
|
||||||
| `components/home/index.js` | چیدمان صفحهٔ اصلی |
|
|
||||||
| `components/home/TextHeader.js` | تنها بخش شهرمحور فعلی |
|
|
||||||
| `components/home/FrequentSearches.js` | فهرست ثابت، یکسان روی همهٔ دامنهها |
|
|
||||||
| `lib/getStateInfo.js` | تشخیص شهر از subdomain (server) |
|
|
||||||
| `lib/rootCity.js` | `isRootCity` — تشخیص دامنهٔ ریشه |
|
|
||||||
| `lib/specialtyContent.js` | `buildSpecialtyIntro` — الگوی متنِ یکتای موجود |
|
|
||||||
| `services/response.js` | `getSpecialtyDoctorCounts` — شمار پزشک هر تخصص در یک شهر |
|
|
||||||
|
|
||||||
## وضعیت فعلی
|
|
||||||
|
|
||||||
### لینک با uuid اختیاری
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// app/component/ItemDoctor.js:41
|
|
||||||
<Link href={`/doctor/${doctor?.uuid}`}>
|
|
||||||
<p className="text-[#3B3B3B] text-[14px] font-bold">
|
|
||||||
{doctor?.display_name || doctor?.name}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
```
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// app/component/ItemDoctor.js:89
|
|
||||||
<Link
|
|
||||||
href={`/doctor/${doctor?.uuid}`}
|
|
||||||
aria-label={`مشاهده پروفایل ${doctor?.display_name || doctor?.name || "پزشک"}`}
|
|
||||||
>
|
|
||||||
```
|
|
||||||
|
|
||||||
### اسکلتی که uuid ندارد
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// app/doctors/loading.js
|
|
||||||
{Array.from({ length: 6 }).map((_, idx) => (
|
|
||||||
<ItemDoctor key={idx} doctor={{ id: idx }} loading setDoctors={() => {}} />
|
|
||||||
))}
|
|
||||||
```
|
|
||||||
|
|
||||||
### تنها بخش شهرمحور صفحهٔ اصلی
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// components/home/TextHeader.js
|
|
||||||
const { matchedCity } = await getStateInfo();
|
|
||||||
...
|
|
||||||
<p className="…">با {matchedCity?.site_name}</p>
|
|
||||||
<h1 className="…">{matchedCity?.slogan}</h1>
|
|
||||||
```
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// components/home/index.js
|
|
||||||
<div className="padding-responsive z-20 min-h-screen py-[120px] flex items-center justify-center flex-col gap-[40px]">
|
|
||||||
<TextHeader />
|
|
||||||
<SearchBar />
|
|
||||||
<FrequentSearches />
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### الگوی متنِ یکتا که از قبل هست و باید تقلید شود
|
|
||||||
|
|
||||||
```js
|
|
||||||
// lib/specialtyContent.js:57
|
|
||||||
export function buildSpecialtyIntro(specialtyName, cityName, doctorCount = 0) {
|
|
||||||
const seed = hash(specialtyName, cityName);
|
|
||||||
return [
|
|
||||||
pick(openingVariants(specialtyName, cityName), seed),
|
|
||||||
pick(bodyVariants(specialtyName, cityName, doctorCount), seed >>> 3),
|
|
||||||
pick(closingVariants(specialtyName, cityName), seed >>> 7),
|
|
||||||
].join(" ");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## وظایف
|
|
||||||
|
|
||||||
### ۱. کارت بدون `uuid` نباید لینک باشد
|
|
||||||
|
|
||||||
در `app/component/ItemDoctor.js` هر دو `Link` مشروط شوند. وقتی `uuid` نیست — اسکلت
|
|
||||||
بارگذاری، یا رکورد ناقص — همان محتوا بدون `<a>` رندر شود.
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
const href = doctor?.uuid ? `/doctor/${doctor.uuid}` : null;
|
|
||||||
```
|
|
||||||
|
|
||||||
- عنوان: اگر `href` تهی بود، `<p>` بدون `Link`.
|
|
||||||
- دکمهٔ فلش: اگر `href` تهی بود، دکمه رندر نشود (در اسکلت هم معنایی ندارد).
|
|
||||||
|
|
||||||
`?.` را از `doctor?.uuid` داخل رشتهٔ الگو حذف کن؛ همان بود که `undefined` را به URL میبرد.
|
|
||||||
|
|
||||||
**نحوه تست:** تست کامپوننتی در `app/component/ItemDoctor.test.js` —
|
|
||||||
پزشک با `uuid` دو لینک به `/doctor/<uuid>` دارد؛ آبجکت `{ id: 0 }` هیچ `role="link"`
|
|
||||||
ندارد و رشتهٔ `undefined` در `container.innerHTML` نیست.
|
|
||||||
سپس build و بررسی HTML واقعی:
|
|
||||||
```bash
|
|
||||||
NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build
|
|
||||||
npm run start &
|
|
||||||
curl -s http://localhost:3000/doctors | grep -c 'doctor/undefined' # باید 0 باشد
|
|
||||||
```
|
|
||||||
|
|
||||||
### ۲. بخش شهرمحور صفحهٔ اصلی
|
|
||||||
|
|
||||||
کامپوننت تازه `components/home/CityHighlights.js` — سرور-کامپوننت، زیر `FrequentSearches`.
|
|
||||||
|
|
||||||
داده از `GET /api/v1/specialties/doctor-counts?city_id=<id>` میآید که از قبل هست و
|
|
||||||
`services/response.js` هم wrapper دارد (`getSpecialtyDoctorCounts`). برای صفحهٔ اصلی
|
|
||||||
server-side است، پس با `lib/req.js` → `fetchReq` بگیر، نه با کلاینت axios.
|
|
||||||
|
|
||||||
محتوا:
|
|
||||||
|
|
||||||
- یک پاراگراف یکتا با همان الگوی `buildSpecialtyIntro`: تابع تازهای کنارش در
|
|
||||||
`lib/specialtyContent.js` به نام `buildCityIntro(cityName, doctorCount, topSpecialties)`
|
|
||||||
بنویس. **الگو Variant-pick با hash است، نه متن ثابت** — دلیلش همان دلیل تابع موجود:
|
|
||||||
متنِ یکسان روی ۳۰ دامنه دوباره همان duplicate را میسازد؛ seed از نام شهر میآید تا
|
|
||||||
هر شهر واگرا شود و خروجی هم پایدار بماند.
|
|
||||||
- شمار واقعی: «N پزشک در M تخصص در <شهر>».
|
|
||||||
- شش تا هشت تخصصِ پرپزشکِ **همان شهر** با شمارشان، لینک به `/specialties/<slug>`.
|
|
||||||
این جای `FrequentSearches` را نمیگیرد؛ آن فهرست ثابت است و این یکی شهرمحور.
|
|
||||||
|
|
||||||
روی دامنهٔ ریشه (`isRootCity`) کل بخش رندر نشود — آنجا شهری وجود ندارد.
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
const { matchedCity, isRoot } = await getStateInfo();
|
|
||||||
if (isRoot || !matchedCity?.id) return null;
|
|
||||||
|
|
||||||
const counts = await safeCounts(matchedCity.id); // خطا → آرایهٔ خالی
|
|
||||||
if (counts.length === 0) return null; // شهر بدون پزشک → بخش حذف
|
|
||||||
```
|
|
||||||
|
|
||||||
خطا را همینجا ببلع و `null` برگردان. صفحهٔ اصلی مهمترین صفحهٔ سایت است و نباید با
|
|
||||||
قطعی API سفید شود؛ این همان مرز واقعی error handling است که پروژه میپذیرد.
|
|
||||||
|
|
||||||
**نحوه تست:**
|
|
||||||
- unit test برای `buildCityIntro` در `lib/specialtyContent.test.js`: دو شهر متفاوت متن
|
|
||||||
متفاوت بدهند؛ یک شهر در دو فراخوانی متن یکسان بدهد (پایداری)؛ `doctorCount` صفر
|
|
||||||
جمله را نشکند.
|
|
||||||
- تست کامپوننتی `components/home/CityHighlights.test.js` با mock روی `getStateInfo` و
|
|
||||||
`fetchReq`: دامنهٔ شهری بخش را میسازد؛ دامنهٔ ریشه `null`؛ خطای شبکه `null`؛
|
|
||||||
فهرست خالی `null`.
|
|
||||||
- سنجهٔ عینیِ معیار پذیرش، بعد از deploy:
|
|
||||||
```bash
|
|
||||||
a=$(curl -s https://behbahan-nobat.ir/ | sed 's/<[^>]*>/ /g' | tr -s ' \n' ' ')
|
|
||||||
b=$(curl -s https://yasuj-nobat.ir/ | sed 's/<[^>]*>/ /g' | tr -s ' \n' ' ')
|
|
||||||
python3 -c "
|
|
||||||
import sys, difflib
|
|
||||||
print('%.1f%%' % (difflib.SequenceMatcher(None, sys.argv[1], sys.argv[2]).ratio()*100))
|
|
||||||
" "$a" "$b"
|
|
||||||
```
|
|
||||||
پیش از تغییر ۹۹٫۰٪ بود؛ باید زیر ۸۵٪ برود.
|
|
||||||
|
|
||||||
### ۳. `generateMetadata` صفحهٔ اصلی
|
|
||||||
|
|
||||||
`app/page.js` الان `generateMetadata` ندارد و از layout ارث میبرد. توضیحات متا هم
|
|
||||||
باید شهرمحور باشد، وگرنه snippet هر ۳۰ دامنه یکی است.
|
|
||||||
|
|
||||||
`description` را از همان `buildCityIntro` بساز (کوتاهشده)، و `alternates.canonical`
|
|
||||||
را دست نزن — لایهٔ layout با `getCanonicalUrl` درستش میکند و صفحهٔ اصلی C1-a است.
|
|
||||||
|
|
||||||
**نحوه تست:** `curl -s https://<city>-nobat.ir/ | grep -oE '<meta name="description" content="[^"]*"'`
|
|
||||||
روی دو دامنه، و متفاوت بودنشان.
|
|
||||||
|
|
||||||
## نکات مهم
|
|
||||||
|
|
||||||
- **قرارداد API عوض نمیشود.** `doctor-counts` از قبل وجود دارد و مستند است. هیچ کاری در
|
|
||||||
`clinicpro` لازم نیست.
|
|
||||||
- **الگو: Variant-pick با hash** — همان که `lib/specialtyContent.js` برای صفحات تخصص
|
|
||||||
دارد. دلیل انتخابش این است که مسئله دقیقاً همان است: یک قالب روی دهها دامنه که اگر
|
|
||||||
ثابت بماند دوباره duplicate میسازد. متن تصادفیِ ناپایدار هم بدتر است، چون هر
|
|
||||||
rebuild محتوا را عوض میکند؛ seed از نام شهر هر دو را حل میکند.
|
|
||||||
- **بخش آمار نباید عدد صفر نشان دهد.** شهرِ بدون پزشک با «۰ پزشک» بدتر از نبودِ بخش است.
|
|
||||||
- **`FrequentSearches` دستنخورده بماند.** آن فهرست ثابتِ تخصصهاست و کارکرد ناوبری دارد؛
|
|
||||||
بخش تازه مکمل آن است نه جایگزینش.
|
|
||||||
- **این تغییر SEO است و اثرش فوری نیست.** بعد از deploy باید در Search Console دوباره
|
|
||||||
ایندکس درخواست شود و هفتهها طول میکشد. در گزارش پایانی این را صریح بنویس تا انتظار
|
|
||||||
اشتباه ساخته نشود.
|
|
||||||
- **`Validate fix` را برای دستههای دیگر نزن.** دلیلش در سند همراه آمده؛ زدنش روی
|
|
||||||
noindex عمدی همیشه شکست میخورد و در Search Console نویز میسازد.
|
|
||||||
- خط پایهٔ فعلی ریپو پیش از این تسک: `npm run test` چهار شکستِ ازقبلموجود در
|
|
||||||
`lib/lib.test.js` و `lib/getStateInfo.test.js`، و `npm run lint` سه خطا در فایلهای
|
|
||||||
بیربط. اینها رگرسیون نیستند و درست کردنشان در محدودهٔ این تسک نیست.
|
|
||||||
@@ -1,469 +0,0 @@
|
|||||||
# بازگرداندن صفحهٔ اصلی، رفع ۴۰۴ اسلاگ فارسی، سه اصلاح صفحهٔ مقاله، و دو اصلاح «درباره ما»
|
|
||||||
|
|
||||||
## پروژه
|
|
||||||
|
|
||||||
`nobat724_front` — سایت عمومی. بدون تغییر بکاند.
|
|
||||||
|
|
||||||
## زمینه
|
|
||||||
|
|
||||||
پنج مورد جدا که کاربر روی محیط محلی دید. یکی بازگرداندن تغییری است که در تسک قبلی
|
|
||||||
اضافه شد، یکی باگ ۴۰۴ با ریشهٔ اثباتشده، و سه اصلاح کوچک روی صفحهٔ مقاله.
|
|
||||||
|
|
||||||
## مشکل / هدف
|
|
||||||
|
|
||||||
### ۱. بخش تازهٔ صفحهٔ اصلی برداشته شود
|
|
||||||
|
|
||||||
در تسک قبلی برای رفع `Duplicate, Google chose different canonical` یک بخش شهرمحور
|
|
||||||
به صفحهٔ اصلی اضافه شد: متن مقدمه، «تخصصهای پرمراجعه در …» و «پزشکان …».
|
|
||||||
کاربر آن را نمیخواهد. صفحهٔ اصلی به حالت قبل برگردد.
|
|
||||||
|
|
||||||
### ۲. اسلاگ فارسی ۴۰۴ میدهد
|
|
||||||
|
|
||||||
```
|
|
||||||
/specialties/%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4 → 404
|
|
||||||
/specialties/gastroenterology → 200
|
|
||||||
```
|
|
||||||
|
|
||||||
**ریشه با ابزارگذاری روی build واقعی اثبات شد** — `generateMetadata` و بدنهٔ صفحه دو
|
|
||||||
مقدار متفاوت میگیرند:
|
|
||||||
|
|
||||||
```
|
|
||||||
generateMetadata → slug = "جراح-گوارش" found: true
|
|
||||||
بدنهٔ صفحه → slug = "%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4" found: false → notFound()
|
|
||||||
```
|
|
||||||
|
|
||||||
یعنی Next در بدنهٔ صفحه segment را decode نمیکند. رشتهها بایتبهبایت یکیاند
|
|
||||||
(هر دو NFC، بدون تفاوت codepoint)، پس مشکل نرمالسازی نیست.
|
|
||||||
|
|
||||||
**این باگ فقط مخصوص صفحهٔ تخصص نیست.** slug مقالهها هم فارسی است
|
|
||||||
(`آلرژی-دارویی-…-58d2e880`)، پس `app/blog/[slug]/page.js` هم همین را دارد و امروز
|
|
||||||
با uuid تست میشود که ASCII است و مشکل را پنهان میکند.
|
|
||||||
|
|
||||||
### ۳. برچسب «مخصوص شهر» از صفحهٔ مقاله حذف شود
|
|
||||||
|
|
||||||
فقط همین جمله. منطق scope شهریِ مقالهها دستنخورده میماند.
|
|
||||||
|
|
||||||
### ۴. تگهای مقاله لینک شوند
|
|
||||||
|
|
||||||
الان چیپهای بدون لینکاند. کامنت کد میگوید «صفحهٔ فیلتر تگ وجود ندارد» — این کامنت
|
|
||||||
کهنه است؛ `components/blogs/index.js` از قبل `?tag=` را میخواند و اعمال میکند.
|
|
||||||
|
|
||||||
### ۵. عکس «مطالب مرتبط» دفرمه است
|
|
||||||
|
|
||||||
عکس داخل `<li className="flex">` است و ظرفش `shrink-0` ندارد. با تیتر بلند، عرض عکس
|
|
||||||
فشرده میشود و ارتفاعش ثابت میماند.
|
|
||||||
|
|
||||||
### ۶. صفحهٔ «درباره ما» راه عضویت پزشک را نمیگوید
|
|
||||||
|
|
||||||
مسیر صفحه:
|
|
||||||
|
|
||||||
```
|
|
||||||
/about-us
|
|
||||||
```
|
|
||||||
|
|
||||||
متن فعلی فقط از دید بیمار نوشته شده. پزشکی که بخواهد به سامانه اضافه شود هیچ
|
|
||||||
راهنماییای نمیبیند. باید بند تازهای اضافه شود که او را به این سایت بفرستد:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://clinic-pro.ir/
|
|
||||||
```
|
|
||||||
|
|
||||||
### ۷. لوگوی هدر «درباره ما» غلط است
|
|
||||||
|
|
||||||
هدر بنفش صفحهٔ «درباره ما» فایل قدیمی را نشان میدهد:
|
|
||||||
|
|
||||||
```
|
|
||||||
/assets/images/logo-2.png
|
|
||||||
```
|
|
||||||
|
|
||||||
هیچجای دیگر سایت این فایل را مصرف نمیکند. لوگوی واقعی سایت این است:
|
|
||||||
|
|
||||||
```
|
|
||||||
/nobat724.svg
|
|
||||||
```
|
|
||||||
|
|
||||||
که هدر اصلی سایت در `app/component/Logo.js` از آن استفاده میکند.
|
|
||||||
|
|
||||||
**دو نکته که سادهترین راهحل را میشکنند:**
|
|
||||||
|
|
||||||
اول، رنگ stroke لوگو `#5559CE` است و پسزمینهٔ هدر `#5559C2`. اگر همان svg را
|
|
||||||
مستقیم بگذارید، لوگو تقریباً نامرئی میشود. باید نسخهٔ روشن رندر شود.
|
|
||||||
|
|
||||||
دوم، `SiteLogo` از قبل همین کار را میکند: هم `city.logo_url` را در نظر میگیرد و
|
|
||||||
هم `stroke` را پارامتری گرفته. مسیرش:
|
|
||||||
|
|
||||||
```
|
|
||||||
components/doctor/poster/SiteLogo.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## معیار پذیرش
|
|
||||||
|
|
||||||
- ✅ موفق: `/specialties/%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4`
|
|
||||||
کد `200` بدهد و تیتر «متخصص جراح گوارش در …» را نشان دهد.
|
|
||||||
- ✅ موفق: `/specialties/gastroenterology` همچنان `200` — اسلاگ لاتین نشکند.
|
|
||||||
- ✅ موفق: صفحهٔ مقاله دیگر «مخصوص شهر:» ندارد، ولی نویسنده و تاریخ سر جایشاناند.
|
|
||||||
- ✅ موفق: هر تگ مقاله لینک به `/blogs?tag=<نام تگ>` است و آن صفحه همان فیلتر را اعمال میکند.
|
|
||||||
- ✅ موفق: HTML صفحهٔ اصلی هیچکدام از «تخصصهای پرمراجعه در»، «پزشکان <شهر>» را ندارد.
|
|
||||||
- ❌ خطا: اسلاگ ناموجود — چه فارسی چه لاتین — همچنان `404` بدهد، نه `200` یا `500`.
|
|
||||||
- ❌ خطا: مقالهای بدون تگ، بلوک تگ را اصلاً رندر نکند.
|
|
||||||
- ⚠️ مرزی: اسلاگ فارسی که با `%` شروع نمیشود ولی حرف فارسی خام دارد هم کار کند.
|
|
||||||
- ⚠️ مرزی: تگ حاوی فاصله یا `&` باید در URL درست encode شود.
|
|
||||||
- ⚠️ مرزی: عکس مطالب مرتبط با تیتر بسیار بلند نباید باریک شود؛ نسبت ابعاد ثابت بماند.
|
|
||||||
- ✅ موفق: صفحهٔ `/about-us` بندی دارد که پزشک را به `https://clinic-pro.ir/` میفرستد،
|
|
||||||
و آن آدرس لینک واقعی است نه متن ساده.
|
|
||||||
- ✅ موفق: لینک عضویت پزشک `target="_blank"` و `rel="noopener noreferrer"` دارد.
|
|
||||||
- ✅ موفق: هیچ ارجاعی به `logo-2.png` در کد نماند و لوگوی هدر «درباره ما» روی
|
|
||||||
پسزمینهٔ بنفش دیده شود.
|
|
||||||
- ❌ خطا: نام سایت در بند تازه هاردکد نشود؛ همان `siteName` که بقیهٔ صفحه استفاده میکند.
|
|
||||||
- ⚠️ مرزی: شهری که `logo_url` دارد باید لوگوی خودش را در هدر ببیند، نه لوگوی پیشفرض.
|
|
||||||
|
|
||||||
## فایلهای مرتبط
|
|
||||||
|
|
||||||
| فایل | نقش |
|
|
||||||
|------|-----|
|
|
||||||
| `components/home/index.js` | چیدمان صفحهٔ اصلی — `CityHighlights` از اینجا حذف شود |
|
|
||||||
| `components/home/CityHighlights.js` | کامپوننتی که باید برداشته شود |
|
|
||||||
| `components/home/CityHighlights.test.js` | تستش هم با آن میرود |
|
|
||||||
| `lib/listingIntro.js` | `buildCityIntro` و `CITY_INTROS` — فقط مصرفکنندهشان صفحهٔ اصلی بود |
|
|
||||||
| `lib/cityIntro.test.js` | تستهای `buildCityIntro` |
|
|
||||||
| `app/specialties/[slug]/page.js` | ۴۰۴ اسلاگ فارسی |
|
|
||||||
| `app/blog/[slug]/page.js` | همان باگ، پنهان چون با uuid تست میشود |
|
|
||||||
| `components/blog/head/index.js` | برچسب «مخصوص شهر» و چیپهای تگ |
|
|
||||||
| `components/blogs/index.js` | خوانندهٔ `?tag=` — تأیید میکند لینک تگ کار میکند |
|
|
||||||
| `components/blog/relatedContent/Item.js` | عکس دفرمه |
|
|
||||||
| `components/aboutUs/index.js` | متن «درباره ما» — بند تازهٔ پزشک اینجا اضافه شود |
|
|
||||||
| `components/aboutUs/Head.js` | هدر بنفش با لوگوی غلط |
|
|
||||||
| `components/doctor/poster/SiteLogo.js` | لوگوی درست، `stroke` پارامتری، پشتیبانی `logo_url` |
|
|
||||||
| `app/component/Logo.js` | لوگوی هدر اصلی — مرجع اینکه لوگوی واقعی کدام است |
|
|
||||||
| `public/assets/images/logo-2.png` | فایل یتیم — بعد از اصلاح هیچ مصرفکنندهای ندارد |
|
|
||||||
|
|
||||||
## وضعیت فعلی
|
|
||||||
|
|
||||||
### صفحهٔ اصلی
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// components/home/index.js
|
|
||||||
<div className="padding-responsive z-20 min-h-screen py-[120px] flex items-center justify-center flex-col gap-[40px]">
|
|
||||||
<TextHeader />
|
|
||||||
<SearchBar />
|
|
||||||
<FrequentSearches />
|
|
||||||
<CityHighlights />
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### صفحهٔ تخصص — دو مصرف متفاوت از یک param
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// app/specialties/[slug]/page.js:18
|
|
||||||
const findSpecialty = (slug) =>
|
|
||||||
specialtiesData.find((item) => item.slug === slug && item.status === 1) ?? null;
|
|
||||||
|
|
||||||
// :45 generateMetadata — اینجا decode شده میرسد
|
|
||||||
const { slug } = await params;
|
|
||||||
const specialty = findSpecialty(slug);
|
|
||||||
if (!specialty) notFound();
|
|
||||||
|
|
||||||
// :78 بدنهٔ صفحه — اینجا encode شده میرسد
|
|
||||||
const { slug } = await params;
|
|
||||||
const specialty = findSpecialty(slug);
|
|
||||||
if (!specialty) notFound();
|
|
||||||
```
|
|
||||||
|
|
||||||
### برچسب شهر
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// components/blog/head/index.js:49
|
|
||||||
{cityName && (
|
|
||||||
<div className="flex items-center justify-start gap-1">
|
|
||||||
<p className="text-[#9B9B9B] text-[11px] md:text-[13px] lg:text-[14px] font-normal">
|
|
||||||
مخصوص شهر:
|
|
||||||
</p>
|
|
||||||
<p className="text-[#9B9B9B] text-[11px] md:text-[14px] lg:text-[16px] font-medium">
|
|
||||||
{cityName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
### تگهای بدون لینک
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// components/blog/head/index.js:90
|
|
||||||
{/* فهرست کامل تگها — تا این تغییر فقط تگ اول در بردکرامب دیده میشد.
|
|
||||||
صفحهٔ فیلتر تگ وجود ندارد، پس چیپها بدون لینک رندر میشوند. */}
|
|
||||||
{tags.length > 0 && (
|
|
||||||
<ul className="flex flex-wrap items-center justify-start gap-2 mt-[12px] md:mt-[16px]">
|
|
||||||
{tags.map((tag, idx) => (
|
|
||||||
<li
|
|
||||||
key={`${tag}-${idx}`}
|
|
||||||
className="bg-[#F5F5F5] text-[#525252] rounded-full px-[12px] py-[4px] text-[11px] md:text-[13px] font-medium"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
ولی فیلتر تگ از قبل هست:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// components/blogs/index.js:16
|
|
||||||
const selectedTag = searchParams.get("tag") || null;
|
|
||||||
// :32
|
|
||||||
params.tag = selectedTag;
|
|
||||||
```
|
|
||||||
|
|
||||||
### عکس مطالب مرتبط
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// components/blog/relatedContent/Item.js
|
|
||||||
<li className="flex w-full relative items-start justify-start gap-[8px] py-[12px]">
|
|
||||||
<CustomLoading width={80} height={72} loading={loading}>
|
|
||||||
<Link href={`/blog/${data.uuid}`}>
|
|
||||||
<img
|
|
||||||
className=" w-[64px] sm:w-[73px] md:w-[85px] lg:w-[97px] h-[56px] sm:h-[64px] md:h-[75px] lg:h-[88px] rounded-[8px] overflow-hidden object-cover "
|
|
||||||
src={cover}
|
|
||||||
alt={data.title || "article"}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</CustomLoading>
|
|
||||||
```
|
|
||||||
|
|
||||||
### هدر «درباره ما»
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// components/aboutUs/Head.js
|
|
||||||
<div className="bg-[#5559C2] rounded-none md:rounded-lg w-full py-[24px] flex flex-col items-center justify-center gap-[13px] md:gap-[16px]">
|
|
||||||
<Image
|
|
||||||
src="/assets/images/logo-2.png"
|
|
||||||
height={46}
|
|
||||||
width={46}
|
|
||||||
alt="logo"
|
|
||||||
/>
|
|
||||||
<h1 className="...">درباره {siteName}</h1>
|
|
||||||
{slogan && <p className="...">{slogan}</p>}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
`Head` الان فقط `siteName` و `slogan` میگیرد؛ `matchedCity` به آن پاس نمیشود.
|
|
||||||
|
|
||||||
### متن «درباره ما»
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// components/aboutUs/index.js — دو بند، هر دو از دید بیمار
|
|
||||||
<p>{siteName} یک سامانهی آنلاین نوبتدهی پزشکی است که …</p>
|
|
||||||
<p>در {siteName} میتوانید بر اساس تخصص، نام پزشک یا مرکز درمانی جستجو کنید …</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
## وظایف
|
|
||||||
|
|
||||||
### ۱. برداشتن بخش شهرمحور صفحهٔ اصلی
|
|
||||||
|
|
||||||
- `<CityHighlights />` و importش از `components/home/index.js` حذف شود.
|
|
||||||
- فایل `components/home/CityHighlights.js` و تستش پاک شوند.
|
|
||||||
- `buildCityIntro` و `CITY_INTROS` در `lib/listingIntro.js` تنها مصرفکنندهشان همین
|
|
||||||
کامپوننت بود؛ با آن پاک شوند. تستهای مربوطه در `lib/cityIntro.test.js` هم بروند.
|
|
||||||
**کد مرده نگه ندارید** — همین اشتباه قبلاً با `buildDoctorsIntro` تکرار شد.
|
|
||||||
- `buildClinicsIntro` و `CLINIC_INTROS` بمانند؛ `/clinics` هنوز مصرفشان میکند.
|
|
||||||
|
|
||||||
**نحوه تست:**
|
|
||||||
```bash
|
|
||||||
grep -rn "CityHighlights\|buildCityIntro" app components lib # باید خالی باشد
|
|
||||||
npm run test && npm run lint
|
|
||||||
NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build && npm run start &
|
|
||||||
curl -s -H "Host: yasuj-nobat.ir" http://localhost:3000/ | grep -cE "تخصصهای پرمراجعه|پزشکان یاسوج" # باید 0
|
|
||||||
```
|
|
||||||
|
|
||||||
### ۲. رفع ۴۰۴ اسلاگ فارسی
|
|
||||||
|
|
||||||
یک helper مشترک بسازید — این باگ در دو صفحه هست و فردا در صفحهٔ سوم هم میآید:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// lib/routeParams.js
|
|
||||||
/**
|
|
||||||
* segment مسیر را به شکل decodeشده برمیگرداند.
|
|
||||||
*
|
|
||||||
* Next در `generateMetadata` مقدار decodeشده میدهد ولی در بدنهٔ همان صفحه مقدار
|
|
||||||
* خام؛ روی اسلاگهای فارسی این یعنی متادیتا درست ساخته میشود و بلافاصله بعدش
|
|
||||||
* صفحه ۴۰۴ میدهد. اثباتشده روی build واقعی، نه فرض.
|
|
||||||
*
|
|
||||||
* decode شکستخورده (درصدِ ناقص در ورودی) نباید صفحه را بشکند؛ همان مقدار خام
|
|
||||||
* برمیگردد تا نتیجهاش ۴۰۴ طبیعی باشد نه ۵۰۰.
|
|
||||||
*/
|
|
||||||
export function decodeRouteParam(value) {
|
|
||||||
if (typeof value !== "string" || !value.includes("%")) return value;
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(value);
|
|
||||||
} catch {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
سپس در هر دو صفحه، بلافاصله بعد از `await params`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const { slug: rawSlug } = await params;
|
|
||||||
const slug = decodeRouteParam(rawSlug);
|
|
||||||
```
|
|
||||||
|
|
||||||
در `app/specialties/[slug]/page.js` هر دو جا (`generateMetadata` و بدنه) و در
|
|
||||||
`app/blog/[slug]/page.js` هم هر دو جا.
|
|
||||||
|
|
||||||
**مواظب `slug` که به کامپوننت پاس میشود باشید:** در صفحهٔ تخصص، `slug` به
|
|
||||||
`SpecialtyDetailPage` میرود و آنجا در breadcrumb به `/specialties/${slug}` تبدیل
|
|
||||||
میشود. مقدار decodeشده آنجا درست است چون `next/link` خودش encode میکند.
|
|
||||||
|
|
||||||
**نحوه تست:**
|
|
||||||
```bash
|
|
||||||
NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build && npm run start &
|
|
||||||
for u in "/specialties/%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4" \
|
|
||||||
"/specialties/gastroenterology" "/specialties/does-not-exist"; do
|
|
||||||
echo -n "$u → "; curl -s -o /dev/null -w "%{http_code}\n" -H "Host: yasuj-nobat.ir" "http://localhost:3000$u"
|
|
||||||
done
|
|
||||||
# انتظار: 200، 200، 404
|
|
||||||
```
|
|
||||||
بهعلاوه unit test برای `decodeRouteParam` در `lib/routeParams.test.js`: مقدار
|
|
||||||
encodeشده، مقدار خام فارسی، مقدار لاتین، رشتهٔ با `%` ناقص (نباید throw کند)،
|
|
||||||
ورودی `undefined`.
|
|
||||||
|
|
||||||
### ۳. حذف برچسب «مخصوص شهر»
|
|
||||||
|
|
||||||
کل بلوک `{cityName && (...)}` در `components/blog/head/index.js` برداشته شود.
|
|
||||||
اگر `cityName` بعد از آن هیچ مصرفی ندارد، محاسبهاش هم پاک شود تا متغیر بلااستفاده نماند.
|
|
||||||
|
|
||||||
منطق scope شهریِ مقالهها (`domainScopeCityId` و `city_id` در fetch) **دست نخورد** —
|
|
||||||
کاربر فقط همین جمله را خواست.
|
|
||||||
|
|
||||||
**نحوه تست:** `curl` صفحهٔ مقاله و `grep -c "مخصوص شهر"` که باید صفر باشد، در حالی که
|
|
||||||
`grep -c "نویسنده:"` همچنان یک است.
|
|
||||||
|
|
||||||
### ۴. لینککردن تگها
|
|
||||||
|
|
||||||
هر چیپ به `/blogs?tag=<نام>` لینک شود. `next/link` خودش encode میکند، ولی چون تگ
|
|
||||||
ممکن است فاصله یا `&` داشته باشد، از شکل شیئی استفاده کنید تا مطمئن شوید:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<Link href={{ pathname: "/blogs", query: { tag } }} className="...">
|
|
||||||
{tag}
|
|
||||||
</Link>
|
|
||||||
```
|
|
||||||
|
|
||||||
کامنت کهنهٔ بالای بلوک («صفحهٔ فیلتر تگ وجود ندارد») حذف یا اصلاح شود؛ همان کامنت
|
|
||||||
باعث شد این قابلیت جا بماند.
|
|
||||||
|
|
||||||
استایل چیپ عوض نشود، فقط `hover` مناسب اضافه شود چون حالا کلیکشدنی است.
|
|
||||||
|
|
||||||
**نحوه تست:** تست کامپوننتی روی `components/blog/head` — مقاله با دو تگ، دو
|
|
||||||
`role="link"` با `href` شامل `/blogs?tag=`؛ مقاله بدون تگ، هیچ لیستی رندر نشود.
|
|
||||||
سپس دستی: کلیک روی تگ باید به `/blogs?tag=…` برود و همان فیلتر اعمال شود.
|
|
||||||
|
|
||||||
### ۵. رفع دفرمگی عکس مطالب مرتبط
|
|
||||||
|
|
||||||
به ظرف عکس `shrink-0` اضافه شود تا در flex فشرده نشود:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<CustomLoading width={80} height={72} loading={loading}>
|
|
||||||
<Link href={`/blog/${data.uuid}`} className="block shrink-0">
|
|
||||||
<img className="... shrink-0 ..." ... />
|
|
||||||
</Link>
|
|
||||||
</CustomLoading>
|
|
||||||
```
|
|
||||||
|
|
||||||
اگر `CustomLoading` خودش ظرف flex میسازد، `shrink-0` باید روی همان بیرونیترین
|
|
||||||
عنصرِ داخل `<li>` بنشیند — اول ساختار رندرشده را ببینید، بعد کلاس را جای درست بگذارید.
|
|
||||||
|
|
||||||
**نحوه تست:** تست کامپوننتی که کلاس `shrink-0` روی ظرف عکس هست. بهعلاوه بررسی
|
|
||||||
چشمی در مرورگر با یک تیتر بسیار بلند — jsdom چیدمان را اندازه نمیگیرد، پس این مورد
|
|
||||||
را **صادقانه بهعنوان «بررسی چشمی» گزارش کنید**، نه بهعنوان تست خودکار.
|
|
||||||
|
|
||||||
### ۶. بند عضویت پزشک در «درباره ما»
|
|
||||||
|
|
||||||
یک بند سوم بعد از دو بند فعلی در `components/aboutUs/index.js` اضافه شود. متن
|
|
||||||
پیشنهادی:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<p>
|
|
||||||
اگر پزشک هستید و میخواهید در {siteName} نوبتدهی آنلاین داشته باشید، ثبتنام
|
|
||||||
از طریق سامانهی کلینیکپرو انجام میشود. کافی است به{" "}
|
|
||||||
<a
|
|
||||||
href="https://clinic-pro.ir/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-[#5559C2] font-medium underline underline-offset-4"
|
|
||||||
>
|
|
||||||
clinic-pro.ir
|
|
||||||
</a>{" "}
|
|
||||||
مراجعه کنید و حساب مطب خود را بسازید. پس از تأیید، پروفایل و برنامهی
|
|
||||||
نوبتدهی شما روی {siteName} نمایش داده میشود.
|
|
||||||
</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
قواعد:
|
|
||||||
|
|
||||||
- نام سایت هاردکد نشود؛ همان `siteName` بالای فایل استفاده شود.
|
|
||||||
- لینک خارجی است، پس `next/link` لازم نیست؛ `<a>` با `target="_blank"` و
|
|
||||||
`rel="noopener noreferrer"` درست است.
|
|
||||||
- استایل بند از بقیه جدا نشود؛ همان ظرف `flex flex-col gap-[16px]` را میگیرد.
|
|
||||||
|
|
||||||
**نحوه تست:**
|
|
||||||
```bash
|
|
||||||
NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build && npm run start &
|
|
||||||
curl -s -H "Host: yasuj-nobat.ir" http://localhost:3000/about-us | grep -c "clinic-pro.ir" # باید ≥ 1
|
|
||||||
```
|
|
||||||
بهعلاوه تست کامپوننتی: لینکی با `href="https://clinic-pro.ir/"` رندر شود و
|
|
||||||
`rel` شامل `noopener` باشد.
|
|
||||||
|
|
||||||
### ۷. اصلاح لوگوی هدر «درباره ما»
|
|
||||||
|
|
||||||
`<Image src="/assets/images/logo-2.png" />` با `SiteLogo` جایگزین شود:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<SiteLogo city={matchedCity} stroke="#FAFAFA" className="w-[46px] h-[46px]" />
|
|
||||||
```
|
|
||||||
|
|
||||||
- `matchedCity` باید از `components/aboutUs/index.js` به `Head` پاس شود؛ الان
|
|
||||||
فقط `siteName` و `slogan` میرود.
|
|
||||||
- `stroke="#FAFAFA"` لازم است چون رنگ پیشفرض روی پسزمینهٔ بنفش گم میشود.
|
|
||||||
- `crossOrigin="anonymous"` داخل `SiteLogo` برای حالت poster گذاشته شده و اینجا
|
|
||||||
ضرری ندارد؛ دست نزنید.
|
|
||||||
- اگر `SiteLogo` جای بهتری لازم دارد چون دیگر فقط مال poster نیست، جابهجاییاش
|
|
||||||
به `components/common/` قابل قبول است — ولی آنوقت هر دو مصرفکننده باید
|
|
||||||
بهروز شوند و تستها سبز بمانند.
|
|
||||||
- بعد از اصلاح، `public/assets/images/logo-2.png` هیچ مصرفکنندهای ندارد و پاک
|
|
||||||
میشود. **اول با grep ثابت کنید یتیم است، بعد پاک کنید.**
|
|
||||||
|
|
||||||
**نحوه تست:**
|
|
||||||
```bash
|
|
||||||
grep -rn "logo-2" app components public --include="*.js" --include="*.json" # باید خالی باشد
|
|
||||||
```
|
|
||||||
بهعلاوه تست کامپوننتی روی `components/aboutUs/Head`: شهر بدون `logo_url` باید
|
|
||||||
svg درونخطی بدهد، شهر با `logo_url` باید `<img>` با همان آدرس بدهد.
|
|
||||||
بررسی چشمی هم لازم است — دیدهشدن لوگو روی بنفش را jsdom نمیسنجد. آن را
|
|
||||||
**صادقانه بهعنوان «بررسی چشمی» گزارش کنید**.
|
|
||||||
|
|
||||||
## نکات مهم
|
|
||||||
|
|
||||||
- **۴۰۴ اسلاگ فارسی، مهمترین بخش این تسک است.** اگر فقط صفحهٔ تخصص درست شود و
|
|
||||||
صفحهٔ مقاله جا بماند، همان باگ با اولین مقالهٔ فارسیاسلاگ برمیگردد. هر دو صفحه
|
|
||||||
در همین تسک اصلاح شوند.
|
|
||||||
- **دلیل helper مشترک بهجای دو تکه کد:** دو مصرفکنندهٔ فعلی و یک الگوی تکرارشونده.
|
|
||||||
این abstraction «برای آینده» نیست؛ همین حالا دو جا لازم است.
|
|
||||||
- **حذف کد مرده جزو کار است.** `buildCityIntro` بعد از برداشتن `CityHighlights`
|
|
||||||
بلااستفاده میشود. نگهداشتنش همان وضعیتی را میسازد که `buildDoctorsIntro` ساخته
|
|
||||||
بود: تابعی که تست دارد ولی هیچجا رندر نمیشود.
|
|
||||||
- **سنجهٔ شباهت صفحهٔ اصلی برمیگردد به حدود ۹۰٪.** تسک قبلی آن را به ۷۱٫۵٪ رسانده
|
|
||||||
بود. این عقبگرد خواستهٔ کاربر است و باید در گزارش پایانی صریح ذکر شود، نه بیصدا.
|
|
||||||
مشکل `Duplicate, Google chose different canonical` روی صفحهٔ اصلی برمیگردد.
|
|
||||||
- **دو مورد «درباره ما» مستقل از پنج مورد قبلیاند.** اگر یکی از آنها گیر کرد،
|
|
||||||
بقیه را کامل کنید و همان یکی را صریح گزارش دهید.
|
|
||||||
- **لوگو مسئلهٔ کنتراست است، نه فقط عوضکردن فایل.** stroke لوگو `#5559CE` و
|
|
||||||
پسزمینه `#5559C2` است. اگر بدون `stroke` روشن جایگزین کنید، تستها سبز
|
|
||||||
میشوند ولی کاربر لوگو را نمیبیند.
|
|
||||||
- خط پایهٔ ریپو پیش از این تسک: `npm run test` چهار شکستِ ازقبلموجود در
|
|
||||||
`lib/lib.test.js` و `lib/getStateInfo.test.js`؛ `npm run lint` سه خطا در فایلهای
|
|
||||||
بیربط. اینها رگرسیون نیستند.
|
|
||||||
- `npm run start` هشدار `"next start" does not work with "output: standalone"`
|
|
||||||
میدهد. صفحات رندر میشوند و برای این تستها کافی است، ولی اگر رفتار عجیبی دیدید
|
|
||||||
اول همین را در نظر بگیرید.
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
# رزرو آنلاین منبعمحور در جریان نوبتگیری
|
|
||||||
|
|
||||||
## پروژه
|
|
||||||
|
|
||||||
`nobat724_front` (سایت عمومی).
|
|
||||||
|
|
||||||
پرامپت همتا در backend: `clinicpro/.claude/prompt/public-resource-booking-api.md`.
|
|
||||||
**آن باید اول اجرا و merge شود** — این پرامپت سه اندپوینتی را مصرف میکند که آنجا ساخته میشوند.
|
|
||||||
|
|
||||||
## زمینه
|
|
||||||
|
|
||||||
در کلینیکپرو، «منبع» هر چیزی است که ممکن است اشغال باشد: پزشک، اپراتور، دستگاه، اتاق، یونیت.
|
|
||||||
هر منبع تقویم مستقل خودش را دارد و سرویسهایی که ارائه میدهد، با مدت و قیمتِ اختصاصیِ همان منبع،
|
|
||||||
روی خودش تعریف میشوند. مالک کلینیک با توگل «نمایش در نوبتدهی آنلاین» روی هر سرویس تعیین میکند
|
|
||||||
که آن سرویس در سایت دیده شود یا نه.
|
|
||||||
|
|
||||||
جریان فعلیِ نوبتگیری سایت فقط پزشکمحور است: محل → (در حالت سرویسی) سرویس → روز → ساعت.
|
|
||||||
|
|
||||||
## مشکل / هدف
|
|
||||||
|
|
||||||
بیمار نمیتواند روی یک دستگاه یا اتاقِ مشخص نوبت بگیرد. هدف: افزودن یک مرحلهٔ «انتخاب منبع» به
|
|
||||||
همان جریان، بدون شکستن مسیر فعلی.
|
|
||||||
|
|
||||||
**تصمیم دامنه (تأییدشده):**
|
|
||||||
|
|
||||||
- نقطهٔ ورود فقط `/appointment/[doctorId]` است. صفحهٔ کلینیک در این تسک دست نمیخورد.
|
|
||||||
- فقط منابعِ همان پزشک نمایش داده میشوند (backend خودش فیلتر میکند؛ سایت فیلتر اضافه نمیزند).
|
|
||||||
- اگر پزشک در آن محل هیچ منبعی نداشته باشد، **هیچ تغییری در تجربهٔ فعلی دیده نمیشود** —
|
|
||||||
مرحلهٔ منبع اصلاً رندر نمیشود.
|
|
||||||
|
|
||||||
## معیار پذیرش
|
|
||||||
|
|
||||||
- ✅ موفق:
|
|
||||||
- پزشکی که در محل انتخابشده منبعِ قابل رزرو دارد: بعد از تأیید محل، مرحلهٔ «انتخاب منبع»
|
|
||||||
دیده میشود با کارت «نوبت با پزشک» بهعلاوهٔ یک کارت به ازای هر منبع.
|
|
||||||
- انتخاب یک منبع → فهرست سرویسهای **همان منبع** با مدت و قیمتِ همان منبع → تقویم → ساعتها
|
|
||||||
از `appointment-resource-slots` → ثبت نوبت با `resource_uuid` در payload → پاسخ ۲۰۱ و رفتن
|
|
||||||
به مرحلهٔ پرداخت، مثل جریان فعلی.
|
|
||||||
- انتخاب «نوبت با پزشک» → دقیقاً جریان امروز، بدون هیچ تفاوتی.
|
|
||||||
- ❌ خطا:
|
|
||||||
- خطای شبکه یا ۴۲۲ روی `getBookingResources` → مرحلهٔ منبع رندر نشود و جریان پزشکمحور ادامه
|
|
||||||
یابد. صفحه نباید سفید شود یا در حالت loading گیر کند.
|
|
||||||
- ثبت نوبت با ۴۰۹ (منبع در آن لحظه پر شد) → پیام فارسی به کاربر و برگشت به مرحلهٔ ساعت،
|
|
||||||
نه خطای خام.
|
|
||||||
- ⚠️ مرزی:
|
|
||||||
- پزشک بدون منبع → صفر تغییر در UI فعلی (این را صریح تست کن، نه فرض).
|
|
||||||
- منبعی که در روز انتخابشده شیفت ندارد → پیام «در حال حاضر، نوبتی برای این روز موجود نمیباشد.»
|
|
||||||
مثل امروز، نه فهرست خالی و بیتوضیح.
|
|
||||||
- تعویض محل بعد از انتخاب منبع → منبع و سرویس و ساعتِ انتخابشده باید باطل شوند. امروز
|
|
||||||
`changeLocation` همین کار را برای سرویس و اسلات میکند؛ منبع هم باید به آن اضافه شود.
|
|
||||||
- تعویض منبع بعد از انتخاب سرویس → سرویسها باطل شوند (سرویسِ منبع الف روی منبع ب معتبر نیست
|
|
||||||
و backend با ۴۲۲ رد میکند).
|
|
||||||
- منبعِ با ظرفیت بیش از ۱: ساعتها ممکن است با نوبتِ موجود همپوشان باشند — این درست است و
|
|
||||||
سایت نباید فیلتر اضافه بزند.
|
|
||||||
|
|
||||||
## فایلهای مرتبط
|
|
||||||
|
|
||||||
| فایل | نقش |
|
|
||||||
|------|-----|
|
|
||||||
| `services/response.js` | wrapper همهٔ فراخوانیهای API |
|
|
||||||
| `components/appointment/index.js` | state جریان نوبت: محل، سرویس، اسلات |
|
|
||||||
| `components/appointment/Container.js` | تصمیمِ اینکه کدام مرحله رندر شود |
|
|
||||||
| `components/appointment/location/LocationSelect.js` | الگوی مرجعِ ظاهرِ کارت انتخاب |
|
|
||||||
| `components/appointment/service/index.js` | مرحلهٔ انتخاب سرویس |
|
|
||||||
| `components/appointment/date/index.js`, `date/hour/index.js` | مرحلهٔ روز و ساعت |
|
|
||||||
| `app/component/date/dateTime/index.js` | فراخوانیِ اسلاتها |
|
|
||||||
| `app/component/date/datePicker/index.js` | تقویم و روزهای غیرفعال ماه |
|
|
||||||
| `components/appointment/detail/SubmitData.js` | ساخت payload و `postAppointment` |
|
|
||||||
| `lib/appointmentSlots.js` | `adaptServiceSlots` — همان شکل `start_times` |
|
|
||||||
|
|
||||||
## وضعیت فعلی
|
|
||||||
|
|
||||||
انتخاب مرحله در `Container.js` یک زنجیرهٔ `if/else` است:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// components/appointment/Container.js
|
|
||||||
let dateStep;
|
|
||||||
if (bookingLocations.length === 0) {
|
|
||||||
dateStep = (
|
|
||||||
<p className="w-full max-w-[520px] mx-auto py-[40px] text-center text-[14px] text-[#7A7A7A]">
|
|
||||||
نوبتدهی آنلاین برای این پزشک فعال نیست.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
} else if (multiLocation && !locationConfirmed) {
|
|
||||||
dateStep = (
|
|
||||||
<LocationSelect locations={bookingLocations} selected={selectedLocation} onSelect={changeLocation} />
|
|
||||||
);
|
|
||||||
} else if (serviceMode && selectedServiceUuids.length === 0) {
|
|
||||||
dateStep = (
|
|
||||||
<ServiceSelect services={bookingServices} onContinue={(uuids) => setSelectedServiceUuids(uuids)} />
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
dateStep = ( <Date ... /> );
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
باطلسازی با تعویض محل:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// components/appointment/index.js
|
|
||||||
const changeLocation = (location) => {
|
|
||||||
setSelectedLocation(location);
|
|
||||||
setLocationConfirmed(true);
|
|
||||||
setSelectedServiceUuids([]);
|
|
||||||
setSelectedSlot(null);
|
|
||||||
setSelectedDate(null);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
اسلاتها امروز فقط دو حالت دارند:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// app/component/date/dateTime/index.js
|
|
||||||
const req = serviceMode
|
|
||||||
? request
|
|
||||||
.getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid)
|
|
||||||
.then(adaptServiceSlots)
|
|
||||||
: request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots);
|
|
||||||
```
|
|
||||||
|
|
||||||
و روزهای غیرفعال تقویم فقط پزشکمحورند:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// app/component/date/datePicker/index.js
|
|
||||||
request
|
|
||||||
.getMonthAvailability(doctorUuid, year, month, clinicUuid)
|
|
||||||
```
|
|
||||||
|
|
||||||
payload ثبت نوبت هنوز `resource_uuid` ندارد:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// components/appointment/detail/SubmitData.js
|
|
||||||
const appointmentPayload = {
|
|
||||||
doctor_uuid: doctor.uuid,
|
|
||||||
clinic_uuid: clinicUuid,
|
|
||||||
slot_start: selectedSlot.start,
|
|
||||||
slot_end: selectedSlot.end,
|
|
||||||
// ...
|
|
||||||
...(selectedServiceUuids?.length ? { service_item_uuids: selectedServiceUuids } : {}),
|
|
||||||
```
|
|
||||||
|
|
||||||
## وظایف
|
|
||||||
|
|
||||||
### ۱. سه wrapper جدید در `services/response.js`
|
|
||||||
|
|
||||||
کنار همتاهای پزشکمحور، با همان سبک و همان `removeTokenHead` (این اندپوینتها عمومیاند):
|
|
||||||
|
|
||||||
```js
|
|
||||||
getBookingResources: (doctor_uuid, clinic_uuid = null) =>
|
|
||||||
api.get(
|
|
||||||
`api/v1/appointment-booking-resources/${doctor_uuid}${clinicQuery(clinic_uuid, "?")}`,
|
|
||||||
removeTokenHead
|
|
||||||
),
|
|
||||||
getResourceSlots: (resource_uuid, date, serviceItemUuids = []) =>
|
|
||||||
api.get(
|
|
||||||
`api/v1/appointment-resource-slots?resource_uuid=${resource_uuid}&date=${date}` +
|
|
||||||
serviceItemUuids
|
|
||||||
.map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`)
|
|
||||||
.join(""),
|
|
||||||
removeTokenHead
|
|
||||||
),
|
|
||||||
getResourceMonthAvailability: (resource_uuid, year, month, serviceItemUuids = []) =>
|
|
||||||
api.get(`api/v1/appointment-resource-month-availability/${resource_uuid}`, {
|
|
||||||
params: { year, month },
|
|
||||||
...removeTokenHead,
|
|
||||||
}),
|
|
||||||
```
|
|
||||||
|
|
||||||
نکته: `service_item_uuids[]` آرایه است و `params` اکسیوس آن را با `[]` سریالایز نمیکند — به همین
|
|
||||||
دلیل `getServiceSlots` موجود هم دستی رشته میسازد. برای `month-availability` هم همان کار را بکن
|
|
||||||
(رشتهٔ query دستی)، وگرنه backend سرویسها را نمیبیند و مدت را نمیتواند حساب کند.
|
|
||||||
|
|
||||||
**نحوه تست:** در devtools، هر سه فراخوانی باید بدون هدر `Authorization` بروند و ۲۰۰ بگیرند.
|
|
||||||
|
|
||||||
### ۲. state منبع در `components/appointment/index.js`
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
const [resources, setResources] = useState([]);
|
|
||||||
const [selectedResource, setSelectedResource] = useState(null); // null = نوبت با پزشک
|
|
||||||
const [resourceConfirmed, setResourceConfirmed] = useState(false);
|
|
||||||
```
|
|
||||||
|
|
||||||
- با تغییر `selectedLocation`، منابع همان محل گرفته شوند:
|
|
||||||
`request.getBookingResources(doctor.uuid, selectedLocation?.clinic_uuid ?? null)`.
|
|
||||||
خطا → `setResources([])` (جریان فعلی ادامه یابد؛ سکوت عمدی است، نه فراموشی — کامنتش را بنویس).
|
|
||||||
- `changeLocation` باید `selectedResource`، `resourceConfirmed` و `resources` را هم باطل کند.
|
|
||||||
- تابع `changeResource(resource)` که `selectedServiceUuids`، `selectedSlot`، `selectedDate` را
|
|
||||||
صفر میکند و `resourceConfirmed = true` میگذارد.
|
|
||||||
- وقتی `resources.length === 0`، مقدار `resourceConfirmed` باید از ابتدا `true` باشد تا مرحله
|
|
||||||
اصلاً رندر نشود.
|
|
||||||
- سرویسهایی که به `ServiceSelect` میروند: اگر منبع انتخاب شده، `selectedResource.services`؛
|
|
||||||
وگرنه همان `selectedLocation?.services` امروز.
|
|
||||||
- وقتی منبع انتخاب شده، جریان **همیشه** سرویسی است (منبع اسلاتِ ثابت ندارد)، حتی اگر
|
|
||||||
`booking_mode` آن محل `slot` باشد.
|
|
||||||
|
|
||||||
**نحوه تست:** با `09390039833 / 09390039833` در پنل `/admin` یک منبع بساز، به آن یک سرویس با
|
|
||||||
«نمایش در نوبتدهی آنلاین» روشن وصل کن، سپس صفحهٔ نوبتِ همان پزشک را در
|
|
||||||
`http://yazd-nobat.localhost:3000/appointment/<doctor-uuid>` باز کن.
|
|
||||||
بعد همان سرویس را در پنل خاموش کن و رفرش بزن — مرحلهٔ منبع باید ناپدید شود.
|
|
||||||
|
|
||||||
### ۳. کامپوننت `components/appointment/resource/index.js`
|
|
||||||
|
|
||||||
کارتهای انتخاب، **دقیقاً با ظاهر و کلاسهای `LocationSelect.js` و `ServiceSelect`** — طراحی
|
|
||||||
جدید نساز، رنگ جدید معرفی نکن. رنگهای موجود: `#5559CE` (اصلی)، `#3B3B3B` (متن)، `#7A7A7A`
|
|
||||||
(متن ثانویه)، `#EFEFEF` (خط).
|
|
||||||
|
|
||||||
محتوای هر کارت:
|
|
||||||
|
|
||||||
- کارت اول همیشه: «نوبت با پزشک» با زیرعنوانِ کوتاه — انتخابش یعنی `changeResource(null)`.
|
|
||||||
- هر منبع: `name` بهعنوان عنوان، `type.name` بهعنوان برچسبِ نوع، تعداد سرویسها و کوتاهترین
|
|
||||||
مدت بهعنوان زیرنویس.
|
|
||||||
- عنوان مرحله: «۱. انتخاب نوع نوبت» — و در نتیجه سرویس «۲.» و ساعت «۳.» میشود. شمارههای
|
|
||||||
موجود در `ServiceSelect` و `Hour` باید هماهنگ شوند، وگرنه دو مرحله هر دو «۱.» میشوند.
|
|
||||||
|
|
||||||
در `Container.js` این مرحله **بعد از** تأیید محل و **قبل از** انتخاب سرویس بنشیند:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
} else if (resources.length > 0 && !resourceConfirmed) {
|
|
||||||
dateStep = (
|
|
||||||
<ResourceSelect resources={resources} onSelect={changeResource} />
|
|
||||||
);
|
|
||||||
} else if (serviceMode && selectedServiceUuids.length === 0) {
|
|
||||||
```
|
|
||||||
|
|
||||||
و در مرحلهٔ روز، دکمهٔ «← تغییر نوع نوبت» کنار «تغییر محل» و «تغییر سرویس» موجود در
|
|
||||||
`components/appointment/date/index.js` اضافه شود (همان سبک، همان کلاس).
|
|
||||||
|
|
||||||
**نحوه تست:** هر سه دکمهٔ بازگشت را بزن و مطمئن شو انتخابهای پاییندستی پاک میشوند — مثلاً
|
|
||||||
بعد از «تغییر نوع نوبت»، سرویس قبلی نباید هنوز انتخاب باشد.
|
|
||||||
|
|
||||||
### ۴. اسلاتها و تقویمِ منبع
|
|
||||||
|
|
||||||
در `app/component/date/dateTime/index.js` یک شاخهٔ سوم:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
const req = resourceUuid
|
|
||||||
? request
|
|
||||||
.getResourceSlots(resourceUuid, dateStr, selectedServiceUuids)
|
|
||||||
.then(adaptServiceSlots)
|
|
||||||
: serviceMode
|
|
||||||
? request.getServiceSlots(...).then(adaptServiceSlots)
|
|
||||||
: request.getAppointmentSlots(...).then(adaptSlots);
|
|
||||||
```
|
|
||||||
|
|
||||||
`adaptServiceSlots` بدون تغییر کار میکند: پاسخ منبع هم `start_times` با همان شکل
|
|
||||||
`{start, end, start_time, end_time}` میدهد. **`lib/appointmentSlots.js` را دست نزن.**
|
|
||||||
|
|
||||||
`resourceUuid` باید از `Container` تا `DateTime` رد شود:
|
|
||||||
`Date` → `Hour` → `DateTime`. همان زنجیرهای که `clinicUuid` امروز طی میکند.
|
|
||||||
|
|
||||||
در `app/component/date/datePicker/index.js`، وقتی `resourceUuid` داده شده،
|
|
||||||
`getResourceMonthAvailability(resourceUuid, year, month, selectedServiceUuids)` جای
|
|
||||||
`getMonthAvailability` بنشیند. کشِ `loadedMonths` باید با تغییر `resourceUuid` هم پاک شود —
|
|
||||||
دقیقاً همان `useEffect`ی که امروز برای `clinicUuid` هست:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
useEffect(() => {
|
|
||||||
loadedMonths.current = new Set();
|
|
||||||
setDisabledSet(new Set());
|
|
||||||
setOnlineEnabled(true);
|
|
||||||
setAutoSelected(false);
|
|
||||||
}, [clinicUuid]); // ← resourceUuid هم به آرایهٔ وابستگی اضافه شود
|
|
||||||
```
|
|
||||||
|
|
||||||
**نحوه تست:** یک منبع با شیفتِ فقط دوشنبه بساز. در تقویم سایت، همهٔ روزها جز دوشنبهها باید
|
|
||||||
غیرفعال باشند. روی یک دوشنبه بزن و ساعتها را ببین.
|
|
||||||
|
|
||||||
### ۵. `resource_uuid` در payload ثبت نوبت
|
|
||||||
|
|
||||||
در `components/appointment/detail/SubmitData.js`:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// نوبتِ منبعمحور: backend مدت و slot_end را از زنجیرهٔ حلِ همین منبع بازمحاسبه
|
|
||||||
// میکند، پس عددِ کلاینت فقط پیشنهاد است.
|
|
||||||
...(resourceUuid ? { resource_uuid: resourceUuid } : {}),
|
|
||||||
```
|
|
||||||
|
|
||||||
`resourceUuid` باید از `Container` به `Detail` و از آنجا به `SubmitData` برسد — همان مسیری که
|
|
||||||
`selectedServiceUuids` و `clinicUuid` امروز طی میکنند.
|
|
||||||
|
|
||||||
مدیریت ۴۰۹: پاسخ backend در این حالت `{ success: false, errors: [{ code, message, field: 'resource_uuid' }] }`
|
|
||||||
است. پیام فارسیِ همان errors نمایش داده شود و کاربر به مرحلهٔ ساعت برگردد.
|
|
||||||
|
|
||||||
**نحوه تست (سناریوی کامل):**
|
|
||||||
۱. در پنل، منبع + سرویسِ روشن بساز.
|
|
||||||
۲. در سایت نوبت بگیر تا مرحلهٔ پرداخت.
|
|
||||||
۳. در پنل `/admin/appointments` نوبت را ببین: باید فیلد منبع را نشان دهد.
|
|
||||||
۴. برای تست ۴۰۹: همان بازه را از پنل روی همان منبع رزرو کن، بعد در تبِ سایت ثبت را بزن.
|
|
||||||
|
|
||||||
## نکات مهم
|
|
||||||
|
|
||||||
- **صفر تغییر برای پزشک بدون منبع.** هر تغییری که مسیر فعلی را حتی یک کلیک عوض کند، اشتباه است.
|
|
||||||
این را با یک پزشکِ بدون منبع دستی تست کن، نه با خواندن کد.
|
|
||||||
- **طراحی جدید ممنوع.** مرحلهٔ منبع باید از کامپوننتها، کلاسها و رنگهای موجود ساخته شود.
|
|
||||||
`LocationSelect.js` مرجع ظاهر است.
|
|
||||||
- RTL، فارسی، تقویم جلالی — مثل بقیهٔ جریان. عدد مدت و قیمت با `toLocaleString("fa-IR")` مثل
|
|
||||||
`toToman()` موجود در `ServiceSelect`.
|
|
||||||
- **باطلسازیِ آبشاری** یک قاعده است نه سهتا استثنا: محل → منبع → سرویس → روز → ساعت. تغییر هر
|
|
||||||
سطح، همهٔ سطوح پایینترش را پاک میکند. اگر این را در یک تابع متمرکز کنی، جای سه `setState`
|
|
||||||
پراکنده، خطای بعدی خودش را نشان میدهد.
|
|
||||||
- **`durations[]` نفرست.** backend در مسیر عمومی آن را نمیپذیرد؛ override مدت ابزار پنل است.
|
|
||||||
- **cross-repo:** تغییر شکل پاسخ در backend اینجا خطای build نمیدهد. بعد از هر تغییر در
|
|
||||||
`clinicpro`، این سه فراخوانی را دستی بررسی کن.
|
|
||||||
@@ -109,6 +109,39 @@ All public pages wrap content in `<Layout name="/path">` from `components/layout
|
|||||||
- `data/state.json` — province/state data, joined to city via `province_id`
|
- `data/state.json` — province/state data, joined to city via `province_id`
|
||||||
- `data/specialties.json` — medical specialties; items with `parent` field are sub-specialties shown in FrequentSearches
|
- `data/specialties.json` — medical specialties; items with `parent` field are sub-specialties shown in FrequentSearches
|
||||||
|
|
||||||
|
### Booking Modes (slot vs service)
|
||||||
|
|
||||||
|
The backend decides how a doctor is booked, **per location**: a doctor can be
|
||||||
|
slot-based in their own office and service-based in a clinic. The mode arrives as
|
||||||
|
`booking_mode` on each entry of `getBookingLocations`, so `components/appointment/index.js`
|
||||||
|
reads it off the *selected* location, never off the doctor.
|
||||||
|
|
||||||
|
| Mode | Flow | Slot source |
|
||||||
|
|---|---|---|
|
||||||
|
| `slot` | date → time | `getAppointmentSlots` → `adaptSlots` |
|
||||||
|
| `service` | **service → date → time** | `getServiceSlots` → `adaptServiceSlots` |
|
||||||
|
|
||||||
|
- `lib/appointmentSlots.js` is the split point. Both adapters return the same shape —
|
||||||
|
an array of sessions `{ start_time, end_time, label, slots }` — which
|
||||||
|
`app/component/date/dateTime/index.js` turns into tabs (`sessions.length > 1`).
|
||||||
|
- **Duration is server data.** `total_duration_minutes` comes from
|
||||||
|
`appointment-service-slots`; never sum `duration_minutes` in the front. The backend
|
||||||
|
formula is going to change to solo/additional minutes, and any parallel client
|
||||||
|
calculation will silently start showing a wrong number. `components/appointment/service/index.js`
|
||||||
|
keeps a client sum only as a labelled fallback with a `console.warn`.
|
||||||
|
- The service step runs *before* the date step, yet `total_duration_minutes` does not
|
||||||
|
depend on the date — the backend computes it before touching that day's shifts — so
|
||||||
|
the picker may ask for it using today's date even if today is closed.
|
||||||
|
- Shift boundaries are **not** derived in the front for service mode: a flat
|
||||||
|
`start_times` list cannot distinguish a break between shifts from a gap left by a
|
||||||
|
booked appointment. One session with the real range is returned instead. See the note
|
||||||
|
in `clinicpro/docs/api/appointment.md`.
|
||||||
|
- User panel: `service_items` and `service_total_minutes` come from
|
||||||
|
`GET /api/v1/appointments/user`. Slot-mode appointments have neither, so every read
|
||||||
|
is guarded — an unguarded `.map` crashes the card for all slot-mode appointments.
|
||||||
|
|
||||||
|
Backend reference: `clinicpro/docs/architecture/booking-modes.md`.
|
||||||
|
|
||||||
### Doctor & Clinic Slugs
|
### Doctor & Clinic Slugs
|
||||||
|
|
||||||
Both use `uuid` as the URL slug: `/doctor/${doctor.uuid}` and `/clinic/${clinic.uuid}`.
|
Both use `uuid` as the URL slug: `/doctor/${doctor.uuid}` and `/clinic/${clinic.uuid}`.
|
||||||
|
|||||||
+4
-1
@@ -16,9 +16,12 @@ export function Providers({ children }) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// `attribute="data-"` یک اتریبیوت با نام تحتاللفظی `data-` میساخت (`<html data-="dark">`)،
|
||||||
|
// نه `data-theme`. هم `globals.css` و هم مستند پروژه از اول `data-theme` را فرض کردهاند،
|
||||||
|
// پس هیچکدام هرگز اجرا نمیشدند.
|
||||||
return (
|
return (
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="data-"
|
attribute="data-theme"
|
||||||
defaultTheme="system"
|
defaultTheme="system"
|
||||||
enableSystem
|
enableSystem
|
||||||
>
|
>
|
||||||
|
|||||||
+3
-12
@@ -3,19 +3,10 @@ import Layout from "@/components/layout/StLayout";
|
|||||||
import { getStateInfo } from "@/lib/getStateInfo";
|
import { getStateInfo } from "@/lib/getStateInfo";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const { matchedCity, matchedState, isRoot } = await getStateInfo();
|
const { matchedCity } = await getStateInfo();
|
||||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
const siteName = matchedCity?.site_name || "نوبت 724";
|
||||||
const cityName = isRoot ? null : matchedCity?.name;
|
const title = `درباره ما | ${siteName}`;
|
||||||
const provinceName = isRoot ? null : matchedState?.name;
|
const description = `آشنایی با ${siteName}، سیستم آنلاین نوبتدهی پزشکی. هدف ما ارائه خدمات سریع و کارآمد رزرو نوبت پزشکی برای همه مردم است.`;
|
||||||
|
|
||||||
// صفحه self-canonical است؛ عنوان و توضیح باید شهری باشند وگرنه ۳۵ دامنه یک
|
|
||||||
// متادیتا اعلام میکنند و همان duplicate با شکل تازه برمیگردد.
|
|
||||||
const title = cityName
|
|
||||||
? `درباره ما | نوبتدهی آنلاین پزشکان ${cityName} | ${siteName}`
|
|
||||||
: `درباره ما | ${siteName}`;
|
|
||||||
const description = cityName
|
|
||||||
? `آشنایی با ${siteName}، سامانه رزرو آنلاین نوبت پزشکان ${cityName}${provinceName ? ` و ${provinceName}` : ""}. جستوجوی پزشک بر اساس تخصص و ثبت نوبت بدون تماس تلفنی.`
|
|
||||||
: `آشنایی با ${siteName}، سیستم آنلاین نوبتدهی پزشکی. هدف ما ارائه خدمات سریع و کارآمد رزرو نوبت پزشکی برای همه مردم است.`;
|
|
||||||
const image = "/assets/images/og-image.png";
|
const image = "/assets/images/og-image.png";
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
|
|||||||
+9
-40
@@ -1,7 +1,7 @@
|
|||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import BlogPage from "@/components/blog";
|
import BlogPage from "@/components/blog";
|
||||||
import Layout from "@/components/layout/StLayout";
|
import Layout from "@/components/layout/StLayout";
|
||||||
import { notFound, permanentRedirect } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { fetchReq } from "@/lib/req";
|
import { fetchReq } from "@/lib/req";
|
||||||
import { getStateInfo } from "@/lib/getStateInfo";
|
import { getStateInfo } from "@/lib/getStateInfo";
|
||||||
import { getBlogCanonicalOrigin } from "@/lib/getCanonicalUrl";
|
import { getBlogCanonicalOrigin } from "@/lib/getCanonicalUrl";
|
||||||
@@ -11,9 +11,7 @@ import {
|
|||||||
findCityById,
|
findCityById,
|
||||||
} from "@/lib/domainHelpers";
|
} from "@/lib/domainHelpers";
|
||||||
import { safeJsonLd } from "@/lib/sanitize";
|
import { safeJsonLd } from "@/lib/sanitize";
|
||||||
import { decodeRouteParam } from "@/lib/routeParams";
|
|
||||||
import { normalizeBlog, blogCover } from "@/helper";
|
import { normalizeBlog, blogCover } from "@/helper";
|
||||||
import legacySlugs from "@/data/blogLegacySlugs.json";
|
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
|
||||||
@@ -40,31 +38,12 @@ async function getScopedBlog(slug) {
|
|||||||
return getBlog(slug, domainScopeCityId({ matchedCity, isRoot }));
|
return getBlog(slug, domainScopeCityId({ matchedCity, isRoot }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* پست را با هر شناسهای که روزی به آن اشاره داشته پیدا میکند.
|
|
||||||
*
|
|
||||||
* بکاند خودش slug و uuid و topic_slug و پسوند ۸ کاراکتریِ uuid را resolve میکند.
|
|
||||||
* چیزی که آنجا قابل resolve نیست، اسلاگهای لاتینِ نسل قبلاند: هیچ ردی در دیتابیس
|
|
||||||
* ندارند چون هنگام تغییر عنوان بازنویسی شدند. Search Console همانها را روی
|
|
||||||
* `behbahan-nobat.ir` ۴۰۴ گزارش کرد، پس نگاشتشان در data/blogLegacySlugs.json
|
|
||||||
* دستی نگه داشته میشود — مقدارِ نگاشت پیشوند uuid است، نه اسلاگ مقصد، تا فیلتر
|
|
||||||
* شهریِ همان درخواست دوباره اعمال شود و پستِ شهر دیگر ریدایرکتِ ۴۰۴ نسازد.
|
|
||||||
*/
|
|
||||||
async function resolveScopedBlog(slug) {
|
|
||||||
const blog = await getScopedBlog(slug);
|
|
||||||
if (blog) return blog;
|
|
||||||
|
|
||||||
const legacyUuidPrefix = legacySlugs[slug];
|
|
||||||
return legacyUuidPrefix ? getScopedBlog(legacyUuidPrefix) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }) {
|
export async function generateMetadata({ params }) {
|
||||||
const { slug: rawSlug } = await params;
|
const { slug } = await params;
|
||||||
const slug = decodeRouteParam(rawSlug);
|
|
||||||
const { matchedCity } = await getStateInfo();
|
const { matchedCity } = await getStateInfo();
|
||||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
const siteName = matchedCity?.site_name || "نوبت 724";
|
||||||
|
|
||||||
const blog = await resolveScopedBlog(slug);
|
const blog = await getScopedBlog(slug);
|
||||||
if (!blog) notFound();
|
if (!blog) notFound();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -103,9 +82,7 @@ export async function generateMetadata({ params }) {
|
|||||||
// دامنه سرو میشد و هرکدام خودش را canonical اعلام میکرد.
|
// دامنه سرو میشد و هرکدام خودش را canonical اعلام میکرد.
|
||||||
// فیلد canonical_url اختصاصی (مثلاً نسخهٔ شهریِ پایپلاین) مقدم است.
|
// فیلد canonical_url اختصاصی (مثلاً نسخهٔ شهریِ پایپلاین) مقدم است.
|
||||||
const origin = getBlogCanonicalOrigin(extractEntityCityId(blog));
|
const origin = getBlogCanonicalOrigin(extractEntityCityId(blog));
|
||||||
// slug اینجا decode شده است؛ canonical باید شکل درصدی را بدهد وگرنه روی
|
const canonical = blog.canonical_url || `${origin}/blog/${slug}`;
|
||||||
// اسلاگ فارسی یک URL با کاراکتر غیر ASCII منتشر میشود.
|
|
||||||
const canonical = blog.canonical_url || `${origin}/blog/${encodeURIComponent(slug)}`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
@@ -138,19 +115,11 @@ export async function generateMetadata({ params }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function Blog({ params }) {
|
async function Blog({ params }) {
|
||||||
const { slug: rawSlug } = await params;
|
const { slug } = await params;
|
||||||
const slug = decodeRouteParam(rawSlug);
|
|
||||||
const slugPath = encodeURIComponent(slug);
|
|
||||||
|
|
||||||
const blog = normalizeBlog(await resolveScopedBlog(slug));
|
const blog = normalizeBlog(await getScopedBlog(slug));
|
||||||
if (!blog) notFound();
|
if (!blog) notFound();
|
||||||
|
|
||||||
// اسلاگ کهنه (عنوان عوض شده یا نسل لاتین) به آدرس قطعی منتقل میشود؛ دو URL برای
|
|
||||||
// یک محتوا باقی نمیماند و لینکهای قدیمیِ ایندکسشده اعتبارشان را میبرند.
|
|
||||||
if (blog.slug && blog.slug !== slug) {
|
|
||||||
permanentRedirect(`/blog/${encodeURIComponent(blog.slug)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blogCityId = extractEntityCityId(blog);
|
const blogCityId = extractEntityCityId(blog);
|
||||||
const blogCity = findCityById(blogCityId);
|
const blogCity = findCityById(blogCityId);
|
||||||
const origin = getBlogCanonicalOrigin(blogCityId);
|
const origin = getBlogCanonicalOrigin(blogCityId);
|
||||||
@@ -196,7 +165,7 @@ async function Blog({ params }) {
|
|||||||
headline: blog.title,
|
headline: blog.title,
|
||||||
...(articleDescription && { description: articleDescription }),
|
...(articleDescription && { description: articleDescription }),
|
||||||
image: blogCover(blog),
|
image: blogCover(blog),
|
||||||
mainEntityOfPage: { "@type": "WebPage", "@id": `${origin}/blog/${slugPath}` },
|
mainEntityOfPage: { "@type": "WebPage", "@id": `${origin}/blog/${slug}` },
|
||||||
...(blog.created && {
|
...(blog.created && {
|
||||||
datePublished: new Date(blog.created * 1000).toISOString(),
|
datePublished: new Date(blog.created * 1000).toISOString(),
|
||||||
}),
|
}),
|
||||||
@@ -247,7 +216,7 @@ async function Blog({ params }) {
|
|||||||
: []),
|
: []),
|
||||||
// آیتم آخر بدون `item` کل BreadcrumbList را نامعتبر میکند
|
// آیتم آخر بدون `item` کل BreadcrumbList را نامعتبر میکند
|
||||||
{ "@type": "ListItem", position: categoryName ? 4 : 3, name: blog.title,
|
{ "@type": "ListItem", position: categoryName ? 4 : 3, name: blog.title,
|
||||||
item: `${origin}/blog/${slugPath}` },
|
item: `${origin}/blog/${slug}` },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
@@ -273,7 +242,7 @@ async function Blog({ params }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Layout>
|
<Layout>
|
||||||
<BlogPage blog={blog} blogs={relatedBlogs} />
|
<BlogPage blog={blog} blogs={relatedBlogs} cityName={blogCity?.name} />
|
||||||
</Layout>
|
</Layout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { getStateInfo } from "@/lib/getStateInfo";
|
|||||||
import { buildClinicParams } from "@/helper";
|
import { buildClinicParams } from "@/helper";
|
||||||
import { listingRobots } from "@/lib/listingRobots";
|
import { listingRobots } from "@/lib/listingRobots";
|
||||||
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
|
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
|
||||||
import { buildClinicsIntro } from "@/lib/listingIntro";
|
|
||||||
import { resolveCityDisplayName } from "@/lib/domainHelpers";
|
|
||||||
|
|
||||||
export async function generateMetadata({ searchParams }) {
|
export async function generateMetadata({ searchParams }) {
|
||||||
const awaitedParams = await searchParams;
|
const awaitedParams = await searchParams;
|
||||||
@@ -81,11 +79,6 @@ export default async function Clinics({ searchParams }) {
|
|||||||
matchedCity={matchedCity}
|
matchedCity={matchedCity}
|
||||||
matchedState={matchedState}
|
matchedState={matchedState}
|
||||||
origin={origin}
|
origin={origin}
|
||||||
// قرینهٔ /doctors — buildClinicsIntro هم نوشته و تست شده بود ولی رندر نمیشد.
|
|
||||||
intro={buildClinicsIntro(
|
|
||||||
resolveCityDisplayName(isRoot ? null : matchedCity),
|
|
||||||
clinics?.meta?.totalRecords
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
+29
-32
@@ -5,7 +5,6 @@ import CustomLoading from "./loading/Custom";
|
|||||||
import TextLoading from "./loading/Text";
|
import TextLoading from "./loading/Text";
|
||||||
import CircularLoading from "./loading/Circular";
|
import CircularLoading from "./loading/Circular";
|
||||||
import DoctorAvatar from "./DoctorAvatar";
|
import DoctorAvatar from "./DoctorAvatar";
|
||||||
import SpecialtyChips from "./SpecialtyChips";
|
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
import ArrowLeftD from "@/components/icons/ArrowLeftD";
|
import ArrowLeftD from "@/components/icons/ArrowLeftD";
|
||||||
@@ -14,12 +13,6 @@ import LikeD from "@/components/icons/LikeD";
|
|||||||
import PointD from "@/components/icons/PointD";
|
import PointD from "@/components/icons/PointD";
|
||||||
|
|
||||||
function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
|
function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
|
||||||
// بدون uuid لینکی وجود ندارد. اسکلتِ بارگذاری آبجکتِ بیuuid میدهد و پیش از این
|
|
||||||
// شش `<a href="/doctor/undefined">` در HTML مینشست؛ Googlebot همان را crawl میکرد
|
|
||||||
// و ۴۰۴ میگرفت (گزارش Search Console، ۳۰ ژوئیه).
|
|
||||||
const href = doctor?.uuid ? `/doctor/${doctor.uuid}` : null;
|
|
||||||
const label = doctor?.display_name || doctor?.name;
|
|
||||||
|
|
||||||
const handleLoading = () => {
|
const handleLoading = () => {
|
||||||
setDoctors((prevDoctors) =>
|
setDoctors((prevDoctors) =>
|
||||||
prevDoctors.map((item) =>
|
prevDoctors.map((item) =>
|
||||||
@@ -44,16 +37,20 @@ function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
|
|||||||
</CircularLoading>
|
</CircularLoading>
|
||||||
<div className="flex flex-col items-start justify-center gap-2">
|
<div className="flex flex-col items-start justify-center gap-2">
|
||||||
<TextLoading loading={loading} width={90} height={15}>
|
<TextLoading loading={loading} width={90} height={15}>
|
||||||
{href ? (
|
<Link href={`/doctor/${doctor?.uuid}`}>
|
||||||
<Link href={href}>
|
<p className="text-[#3B3B3B] text-[14px] font-bold">
|
||||||
<p className="text-[#3B3B3B] text-[14px] font-bold">{label}</p>
|
{doctor?.display_name || doctor?.name}
|
||||||
</Link>
|
</p>
|
||||||
) : (
|
</Link>
|
||||||
<p className="text-[#3B3B3B] text-[14px] font-bold">{label}</p>
|
|
||||||
)}
|
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
<TextLoading loading={loading} width={120} height={15}>
|
<TextLoading loading={loading} width={120} height={15}>
|
||||||
<SpecialtyChips specialties={doctor?.specialties} />
|
<p className="text-[#616161] text-[14px] font-normal">
|
||||||
|
تخصص:
|
||||||
|
{doctor?.specialties?.map(
|
||||||
|
(item, idx) =>
|
||||||
|
`${item.name} ${doctor.specialties.length === idx + 1 ? "" : "|"} `
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
<CustomLoading loading={loading} width={100} height={25}>
|
<CustomLoading loading={loading} width={100} height={25}>
|
||||||
<div className="flex rounded items-center justify-start gap-2">
|
<div className="flex rounded items-center justify-start gap-2">
|
||||||
@@ -92,23 +89,23 @@ function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
|
|||||||
: "نوبتدهی غیرفعال است"}
|
: "نوبتدهی غیرفعال است"}
|
||||||
</p>
|
</p>
|
||||||
</CustomLoading>
|
</CustomLoading>
|
||||||
{/* Arrow — روی کارتِ بدون uuid (اسکلت بارگذاری) اصلاً رندر نمیشود؛ دکمهای که
|
{/* Arrow */}
|
||||||
به جایی نمیرود، هم بیمعناست هم لینکِ شکسته در HTML میگذارد. */}
|
<Link
|
||||||
{href && (
|
href={`/doctor/${doctor?.uuid}`}
|
||||||
<Link href={href} aria-label={`مشاهده پروفایل ${label || "پزشک"}`}>
|
aria-label={`مشاهده پروفایل ${doctor?.display_name || doctor?.name || "پزشک"}`}
|
||||||
<Button
|
>
|
||||||
variant="contained"
|
<Button
|
||||||
onClick={handleLoading}
|
variant="contained"
|
||||||
disabled={doctor?.loading}
|
onClick={handleLoading}
|
||||||
aria-label={`مشاهده پروفایل ${label || "پزشک"}`}
|
disabled={doctor?.loading}
|
||||||
className="!p-[14px] !absolute !left-4 !bottom-4 !rounded-full !w-fit"
|
aria-label={`مشاهده پروفایل ${doctor?.display_name || doctor?.name || "پزشک"}`}
|
||||||
>
|
className="!p-[14px] !absolute !left-4 !bottom-4 !rounded-full !w-fit"
|
||||||
<div className={doctor && doctor.loading ? "opacity-0" : ""}>
|
>
|
||||||
<ArrowLeftD />
|
<div className={doctor && doctor.loading ? "opacity-0" : ""}>
|
||||||
</div>
|
<ArrowLeftD />
|
||||||
</Button>
|
</div>
|
||||||
</Link>
|
</Button>
|
||||||
)}
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import ItemDoctor from "@/app/component/ItemDoctor";
|
|
||||||
import Loading from "@/app/doctors/loading";
|
|
||||||
|
|
||||||
const DOCTOR = {
|
|
||||||
id: 1,
|
|
||||||
uuid: "84b7ea9e-2fe1-4413-906d-7a5bf05e6f8f",
|
|
||||||
name: "محمدباقر جهانتاب",
|
|
||||||
specialties: [{ id: "13", name: "جراحی عمومی", parent_id: null }],
|
|
||||||
active: 1,
|
|
||||||
free_turn: "شنبه ۱۷:۴۵",
|
|
||||||
};
|
|
||||||
|
|
||||||
const noop = () => {};
|
|
||||||
|
|
||||||
describe("ItemDoctor — لینک پروفایل", () => {
|
|
||||||
it("پزشک با uuid لینک میگیرد", () => {
|
|
||||||
render(<ItemDoctor doctor={DOCTOR} setDoctors={noop} />);
|
|
||||||
|
|
||||||
const links = screen.getAllByRole("link");
|
|
||||||
expect(links.length).toBeGreaterThan(0);
|
|
||||||
for (const link of links) {
|
|
||||||
expect(link).toHaveAttribute("href", `/doctor/${DOCTOR.uuid}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("کارت بدون uuid هیچ لینکی ندارد", () => {
|
|
||||||
render(<ItemDoctor doctor={{ id: 0 }} loading setDoctors={noop} />);
|
|
||||||
|
|
||||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("رشتهٔ undefined هرگز در HTML نمینشیند", () => {
|
|
||||||
// ریشهٔ خطای Not found (404) در Search Console: href="/doctor/undefined".
|
|
||||||
const { container } = render(
|
|
||||||
<ItemDoctor doctor={{ id: 0 }} loading setDoctors={noop} />
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container.innerHTML).not.toContain("undefined");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("نام پزشک حتی بدون لینک نمایش داده میشود", () => {
|
|
||||||
render(<ItemDoctor doctor={{ id: 0, name: "بدون شناسه" }} setDoctors={noop} />);
|
|
||||||
|
|
||||||
expect(screen.getByText("بدون شناسه")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("display_name بر name مقدم است", () => {
|
|
||||||
render(
|
|
||||||
<ItemDoctor
|
|
||||||
doctor={{ ...DOCTOR, display_name: "دکتر جهانتاب" }}
|
|
||||||
setDoctors={noop}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("دکتر جهانتاب")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("اسکلت بارگذاری /doctors", () => {
|
|
||||||
it("هیچ لینک doctor/undefined تولید نمیکند", () => {
|
|
||||||
const { container } = render(<Loading />);
|
|
||||||
|
|
||||||
expect(container.innerHTML).not.toContain("/doctor/undefined");
|
|
||||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("همچنان شش کارت اسکلت میسازد", () => {
|
|
||||||
const { container } = render(<Loading />);
|
|
||||||
|
|
||||||
expect(container.querySelectorAll("li")).toHaveLength(6);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Popover } from "@mui/material";
|
|
||||||
import { splitSpecialties } from "@/lib/specialtyDisplay";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* تخصص اصلی + شمارندهٔ بقیه در کارت پزشک.
|
|
||||||
*
|
|
||||||
* پیش از این همهٔ نامها پشتسرهم چاپ میشدند و کارتِ پزشکِ ششتخصصی در موبایل
|
|
||||||
* چند برابر بقیه بلند میشد و شبکه را بههم میریخت.
|
|
||||||
*
|
|
||||||
* جدا از ItemDoctor است تا مرز client فقط همینجا باشد؛ کارت از کامپوننتهای
|
|
||||||
* سروری هم رندر میشود.
|
|
||||||
*/
|
|
||||||
function SpecialtyChips({ specialties }) {
|
|
||||||
const [anchorEl, setAnchorEl] = useState(null);
|
|
||||||
const { primary, rest } = splitSpecialties(specialties);
|
|
||||||
|
|
||||||
if (!primary) return null;
|
|
||||||
|
|
||||||
const open = Boolean(anchorEl);
|
|
||||||
|
|
||||||
// کارت داخل <Link> است؛ بدون این دو، هر کلیک روی شمارنده صفحهٔ پزشک را باز میکند.
|
|
||||||
const handleOpen = (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
setAnchorEl(event.currentTarget);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = (event) => {
|
|
||||||
event?.preventDefault?.();
|
|
||||||
event?.stopPropagation?.();
|
|
||||||
setAnchorEl(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5 min-w-0">
|
|
||||||
<span className="text-[#616161] text-[14px] font-normal truncate">
|
|
||||||
تخصص: {primary.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{rest.length > 0 && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleOpen}
|
|
||||||
aria-label={`نمایش ${rest.length} تخصص دیگر`}
|
|
||||||
className="shrink-0 p-1 bg-[#F8F8FF] rounded-[4px] text-[#616161] text-[12px] font-medium leading-none"
|
|
||||||
>
|
|
||||||
+{rest.length}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Popover
|
|
||||||
open={open}
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
onClose={handleClose}
|
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
|
||||||
transformOrigin={{ vertical: "top", horizontal: "right" }}
|
|
||||||
>
|
|
||||||
<ul
|
|
||||||
dir="rtl"
|
|
||||||
className="p-3 flex flex-col gap-1.5 max-w-[260px] bg-[#FFF]"
|
|
||||||
>
|
|
||||||
{[primary, ...rest].map((item) => (
|
|
||||||
<li
|
|
||||||
key={item.id ?? item.name}
|
|
||||||
className="text-[#616161] text-[13px] font-normal"
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Popover>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SpecialtyChips;
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import SpecialtyChips from "@/app/component/SpecialtyChips";
|
|
||||||
|
|
||||||
const root = { id: "13", name: "جراحی عمومی", parent_id: null };
|
|
||||||
const gi = { id: "169", name: "جراح گوارش", parent_id: "13" };
|
|
||||||
const thyroid = { id: "168", name: "جراح تیروئید", parent_id: "13" };
|
|
||||||
|
|
||||||
describe("SpecialtyChips", () => {
|
|
||||||
it("تخصص اصلی را نشان میدهد و بقیه را میشمارد", () => {
|
|
||||||
render(<SpecialtyChips specialties={[gi, root, thyroid]} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole("button", { name: "نمایش 2 تخصص دیگر" })).toHaveTextContent("+2");
|
|
||||||
|
|
||||||
// بقیه تا پیش از کلیک در DOM نیستند — همین کارت را کوتاه نگه میدارد.
|
|
||||||
expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("کلیک روی شمارنده فهرست کامل را باز میکند", () => {
|
|
||||||
render(<SpecialtyChips specialties={[root, gi, thyroid]} />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "نمایش 2 تخصص دیگر" }));
|
|
||||||
|
|
||||||
expect(screen.getByText("جراح گوارش")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("جراح تیروئید")).toBeInTheDocument();
|
|
||||||
// تخصص اصلی هم در فهرست هست تا تصویر کامل باشد.
|
|
||||||
expect(screen.getAllByText(/جراحی عمومی/).length).toBeGreaterThan(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("کلیک روی شمارنده نباید لینک کارت را فعال کند", () => {
|
|
||||||
// کارت داخل <Link> است؛ بدون preventDefault/stopPropagation هر بار که کاربر
|
|
||||||
// تخصصها را میبیند به صفحهٔ پزشک پرت میشود.
|
|
||||||
const onParentClick = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<Link href="/doctor/uuid" onClick={onParentClick}>
|
|
||||||
<SpecialtyChips specialties={[root, gi]} />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
|
|
||||||
// fireEvent مقدار false میدهد وقتی preventDefault صدا زده شده باشد.
|
|
||||||
const notPrevented = fireEvent.click(
|
|
||||||
screen.getByRole("button", { name: "نمایش 1 تخصص دیگر" })
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(notPrevented).toBe(false);
|
|
||||||
expect(onParentClick).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("تکتخصص هیچ شمارندهای ندارد", () => {
|
|
||||||
render(<SpecialtyChips specialties={[root]} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("پزشک بدون تخصص چیزی رندر نمیکند و کرش نمیکند", () => {
|
|
||||||
const { container: empty } = render(<SpecialtyChips specialties={[]} />);
|
|
||||||
expect(empty).toBeEmptyDOMElement();
|
|
||||||
|
|
||||||
const { container: missing } = render(<SpecialtyChips specialties={undefined} />);
|
|
||||||
expect(missing).toBeEmptyDOMElement();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("شمارنده متن دارد نه فقط علامت — برای صفحهخوان", () => {
|
|
||||||
render(<SpecialtyChips specialties={[root, gi, thyroid]} />);
|
|
||||||
|
|
||||||
const button = screen.getByRole("button");
|
|
||||||
expect(button).toHaveAttribute("type", "button");
|
|
||||||
expect(button).toHaveAccessibleName("نمایش 2 تخصص دیگر");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -21,7 +21,7 @@ function gregorianMonthsOf(jalaliMonth) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function DatePicker({ setDate, clinicUuid = null, resourceUuid = null, selectedServiceUuids = [] }) {
|
function DatePicker({ setDate, clinicUuid = null }) {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const doctorUuid = params?.doctorId;
|
const doctorUuid = params?.doctorId;
|
||||||
|
|
||||||
@@ -36,20 +36,15 @@ function DatePicker({ setDate, clinicUuid = null, resourceUuid = null, selectedS
|
|||||||
|
|
||||||
// هر محل، روزهای غیرفعال و وضعیت نوبتدهی آنلاین خودش را دارد؛ کش ماهها باید
|
// هر محل، روزهای غیرفعال و وضعیت نوبتدهی آنلاین خودش را دارد؛ کش ماهها باید
|
||||||
// با تعویض محل دور ریخته شود وگرنه تقویم محل قبلی باقی میماند.
|
// با تعویض محل دور ریخته شود وگرنه تقویم محل قبلی باقی میماند.
|
||||||
//
|
|
||||||
// منبع هم همینطور: تقویم هر دستگاه مالِ خودش است، و مدتِ سرویسهای انتخابشده
|
|
||||||
// تعیین میکند کدام روز جا دارد — پس تغییر هرکدام یعنی کشِ باطل.
|
|
||||||
const serviceKey = selectedServiceUuids.join(",");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadedMonths.current = new Set();
|
loadedMonths.current = new Set();
|
||||||
setDisabledSet(new Set());
|
setDisabledSet(new Set());
|
||||||
setOnlineEnabled(true);
|
setOnlineEnabled(true);
|
||||||
setAutoSelected(false);
|
setAutoSelected(false);
|
||||||
}, [clinicUuid, resourceUuid, serviceKey]);
|
}, [clinicUuid]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!doctorUuid && !resourceUuid) return;
|
if (!doctorUuid) return;
|
||||||
|
|
||||||
const targets = [
|
const targets = [
|
||||||
...gregorianMonthsOf(baseMonth),
|
...gregorianMonthsOf(baseMonth),
|
||||||
@@ -60,13 +55,8 @@ function DatePicker({ setDate, clinicUuid = null, resourceUuid = null, selectedS
|
|||||||
|
|
||||||
targets.forEach(({ year, month }) => {
|
targets.forEach(({ year, month }) => {
|
||||||
loadedMonths.current.add(`${year}-${month}`);
|
loadedMonths.current.add(`${year}-${month}`);
|
||||||
// منبع پرچمِ `online_booking_enabled` ندارد — آن روی برنامهٔ هفتگیِ پزشک است.
|
request
|
||||||
// نبودِ فیلد یعنی «روشن»، و شرطِ پایینتر فقط با `false` صریح واکنش نشان میدهد.
|
.getMonthAvailability(doctorUuid, year, month, clinicUuid)
|
||||||
const req = resourceUuid
|
|
||||||
? request.getResourceMonthAvailability(resourceUuid, year, month, selectedServiceUuids)
|
|
||||||
: request.getMonthAvailability(doctorUuid, year, month, clinicUuid);
|
|
||||||
|
|
||||||
req
|
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const data = res?.data ?? {};
|
const data = res?.data ?? {};
|
||||||
if (data.online_booking_enabled === false) setOnlineEnabled(false);
|
if (data.online_booking_enabled === false) setOnlineEnabled(false);
|
||||||
@@ -83,7 +73,7 @@ function DatePicker({ setDate, clinicUuid = null, resourceUuid = null, selectedS
|
|||||||
loadedMonths.current.delete(`${year}-${month}`);
|
loadedMonths.current.delete(`${year}-${month}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [doctorUuid, baseMonth, clinicUuid, resourceUuid, serviceKey]);
|
}, [doctorUuid, baseMonth, clinicUuid]);
|
||||||
|
|
||||||
const isDisabled = (date) => {
|
const isDisabled = (date) => {
|
||||||
const day = moment(date).startOf("day");
|
const day = moment(date).startOf("day");
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ function DateTime({
|
|||||||
serviceMode = false,
|
serviceMode = false,
|
||||||
selectedServiceUuids = [],
|
selectedServiceUuids = [],
|
||||||
clinicUuid = null,
|
clinicUuid = null,
|
||||||
resourceUuid = null,
|
|
||||||
selectedLocation = null,
|
selectedLocation = null,
|
||||||
}) {
|
}) {
|
||||||
const [value, setValue] = useState(0);
|
const [value, setValue] = useState(0);
|
||||||
@@ -49,17 +48,11 @@ function DateTime({
|
|||||||
setHour();
|
setHour();
|
||||||
const dateStr = moment.unix(date).format("YYYY-MM-DD");
|
const dateStr = moment.unix(date).format("YYYY-MM-DD");
|
||||||
|
|
||||||
// منبع تقویم خودش را دارد، پس زمانهایش از اندپوینت خودش میآید نه از برنامهٔ
|
const req = serviceMode
|
||||||
// هفتگی پزشک. شکل پاسخ همان `start_times` است، پس adapter مشترک میماند.
|
|
||||||
const req = resourceUuid
|
|
||||||
? request
|
? request
|
||||||
.getResourceSlots(resourceUuid, dateStr, selectedServiceUuids)
|
.getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid)
|
||||||
.then(adaptServiceSlots)
|
.then(adaptServiceSlots)
|
||||||
: serviceMode
|
: request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots);
|
||||||
? request
|
|
||||||
.getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid)
|
|
||||||
.then(adaptServiceSlots)
|
|
||||||
: request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots);
|
|
||||||
|
|
||||||
req
|
req
|
||||||
.then((parsed) => {
|
.then((parsed) => {
|
||||||
@@ -76,7 +69,7 @@ function DateTime({
|
|||||||
setAppo("در حال حاضر، نوبتی برای این روز موجود نمیباشد.");
|
setAppo("در حال حاضر، نوبتی برای این روز موجود نمیباشد.");
|
||||||
setValue(0);
|
setValue(0);
|
||||||
});
|
});
|
||||||
}, [date, doctor?.uuid, serviceMode, selectedServiceUuids, clinicUuid, resourceUuid]);
|
}, [date, doctor?.uuid, serviceMode, selectedServiceUuids, clinicUuid]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, waitFor } from '@testing-library/react';
|
|
||||||
|
|
||||||
vi.mock('@/services/response', () => ({
|
|
||||||
request: {
|
|
||||||
getAppointmentSlots: vi.fn(() => Promise.resolve({ data: { sessions: [] } })),
|
|
||||||
getServiceSlots: vi.fn(() => Promise.resolve({ data: { start_times: [] } })),
|
|
||||||
getResourceSlots: vi.fn(() => Promise.resolve({ data: { start_times: [] } })),
|
|
||||||
getMonthAvailability: vi.fn(() => Promise.resolve({ data: {} })),
|
|
||||||
getResourceMonthAvailability: vi.fn(() => Promise.resolve({ data: {} })),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({ useParams: () => ({ doctorId: 'doc-1' }) }));
|
|
||||||
vi.mock('@/components/common/InlineJalaliMonth', () => ({ default: () => <div /> }));
|
|
||||||
vi.mock('./dateTime/hours', () => ({ default: () => <div /> }));
|
|
||||||
vi.mock('./dateTime/SendAppo', () => ({ default: () => <div /> }));
|
|
||||||
vi.mock('../Tabs', () => ({ default: () => <div /> }));
|
|
||||||
|
|
||||||
import { request } from '@/services/response';
|
|
||||||
import DateTime from './dateTime';
|
|
||||||
import DatePicker from './datePicker';
|
|
||||||
|
|
||||||
// نیمهشبِ فردا — تا «زمانِ گذشته» جریان را کوتاه نکند.
|
|
||||||
const TOMORROW = Math.floor(new Date(new Date().setHours(24, 0, 0, 0)).getTime() / 1000);
|
|
||||||
|
|
||||||
describe('DateTime — انتخاب اندپوینت اسلات', () => {
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
|
||||||
|
|
||||||
it('بدون منبع و بدون حالت سرویسی → اسلات پزشک', async () => {
|
|
||||||
render(<DateTime date={TOMORROW} doctor={{ uuid: 'doc-1' }} />);
|
|
||||||
|
|
||||||
await waitFor(() => expect(request.getAppointmentSlots).toHaveBeenCalled());
|
|
||||||
expect(request.getResourceSlots).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('حالت سرویسیِ پزشک → اسلات سرویسیِ پزشک', async () => {
|
|
||||||
render(
|
|
||||||
<DateTime date={TOMORROW} doctor={{ uuid: 'doc-1' }} serviceMode selectedServiceUuids={['s1']} />
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => expect(request.getServiceSlots).toHaveBeenCalled());
|
|
||||||
expect(request.getResourceSlots).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('با منبع → اسلات منبع، حتی وقتی برنامهٔ محل اسلاتی است', async () => {
|
|
||||||
render(
|
|
||||||
<DateTime
|
|
||||||
date={TOMORROW}
|
|
||||||
doctor={{ uuid: 'doc-1' }}
|
|
||||||
resourceUuid="r1"
|
|
||||||
selectedServiceUuids={['s1']}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => expect(request.getResourceSlots).toHaveBeenCalledWith('r1', expect.any(String), ['s1']));
|
|
||||||
expect(request.getAppointmentSlots).not.toHaveBeenCalled();
|
|
||||||
expect(request.getServiceSlots).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DatePicker — انتخاب اندپوینت تقویم ماه', () => {
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
|
||||||
|
|
||||||
it('بدون منبع → تقویم پزشک', async () => {
|
|
||||||
render(<DatePicker setDate={vi.fn()} />);
|
|
||||||
|
|
||||||
await waitFor(() => expect(request.getMonthAvailability).toHaveBeenCalled());
|
|
||||||
expect(request.getResourceMonthAvailability).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('با منبع → تقویم منبع، همراه سرویسهای انتخابشده', async () => {
|
|
||||||
render(<DatePicker setDate={vi.fn()} resourceUuid="r1" selectedServiceUuids={['s1']} />);
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(request.getResourceMonthAvailability).toHaveBeenCalledWith(
|
|
||||||
'r1',
|
|
||||||
expect.any(Number),
|
|
||||||
expect.any(Number),
|
|
||||||
['s1']
|
|
||||||
)
|
|
||||||
);
|
|
||||||
expect(request.getMonthAvailability).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,30 +1,5 @@
|
|||||||
import ContactUsPage from "@/components/contactUs";
|
import ContactUsPage from "@/components/contactUs";
|
||||||
import Layout from "@/components/layout/StLayout";
|
import Layout from "@/components/layout/StLayout";
|
||||||
import { getStateInfo } from "@/lib/getStateInfo";
|
|
||||||
|
|
||||||
export async function generateMetadata() {
|
|
||||||
const { matchedCity, matchedState, isRoot } = await getStateInfo();
|
|
||||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
|
||||||
const cityName = isRoot ? null : matchedCity?.name;
|
|
||||||
const provinceName = isRoot ? null : matchedState?.name;
|
|
||||||
|
|
||||||
// صفحه self-canonical است؛ عنوان و توضیح هم باید شهری باشند وگرنه ۳۵ دامنه
|
|
||||||
// یک متادیتا اعلام میکنند.
|
|
||||||
const title = cityName
|
|
||||||
? `تماس با ما | نوبتدهی آنلاین ${cityName} | ${siteName}`
|
|
||||||
: `تماس با ما | ${siteName}`;
|
|
||||||
const description = cityName
|
|
||||||
? `راههای ارتباط با ${siteName}: تلفن، واتساپ و ایمیل. پشتیبانی رزرو و لغو نوبت پزشکان ${cityName}${provinceName ? ` و ${provinceName}` : ""}.`
|
|
||||||
: `راههای ارتباط با ${siteName}: تلفن، واتساپ و ایمیل. پشتیبانی رزرو و لغو نوبت آنلاین پزشکان.`;
|
|
||||||
const image = "/assets/images/og-image.png";
|
|
||||||
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
openGraph: { title, description, images: [image] },
|
|
||||||
twitter: { card: "summary_large_image", title, description, images: [image] },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContactUs() {
|
function ContactUs() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { getEntityOrigin } from "@/lib/getCanonicalUrl";
|
|||||||
import { extractEntityCityId, findCityById } from "@/lib/domainHelpers";
|
import { extractEntityCityId, findCityById } from "@/lib/domainHelpers";
|
||||||
import { buildDoctorFaq } from "@/lib/specialtyContent";
|
import { buildDoctorFaq } from "@/lib/specialtyContent";
|
||||||
import { isNoindexDoctor } from "@/lib/entityQuality";
|
import { isNoindexDoctor } from "@/lib/entityQuality";
|
||||||
import { splitSpecialties } from "@/lib/specialtyDisplay";
|
|
||||||
import specialtiesData from "@/data/specialties.json";
|
import specialtiesData from "@/data/specialties.json";
|
||||||
import { safeJsonLd } from "@/lib/sanitize";
|
import { safeJsonLd } from "@/lib/sanitize";
|
||||||
import { imageUrl, doctorTitle } from "@/helper";
|
import { imageUrl, doctorTitle } from "@/helper";
|
||||||
@@ -65,28 +64,6 @@ const getBookingLocations = cache(async (doctorUuid) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* منابع قابل رزروِ پزشک (دستگاه، اتاق، یونیت) در همهٔ محیطهایش.
|
|
||||||
*
|
|
||||||
* جدا از محلها گرفته میشود چون منبع تقویم خودش را دارد: پزشکی که برنامهٔ هفتگی ندارد
|
|
||||||
* هیچ محلی برنمیگرداند، ولی ممکن است دستگاهش کاملاً قابل رزرو باشد.
|
|
||||||
*/
|
|
||||||
const getBookingResources = cache(async (doctorUuid) => {
|
|
||||||
if (!doctorUuid) return [];
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_URL}/api/v1/appointment-booking-resources/${doctorUuid}`,
|
|
||||||
{ next: { revalidate: 3600, tags: [`booking-resources-${doctorUuid}`] } }
|
|
||||||
);
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const json = await res.json();
|
|
||||||
const data = json?.data?.data ?? json?.data ?? {};
|
|
||||||
return Array.isArray(data.resources) ? data.resources : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }) {
|
export async function generateMetadata({ params }) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const { matchedCity } = await getStateInfo();
|
const { matchedCity } = await getStateInfo();
|
||||||
@@ -166,13 +143,9 @@ async function Doctor({ params }) {
|
|||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [addresses, bookingLocations, bookingResources] = doctor
|
const [addresses, bookingLocations] = doctor
|
||||||
? await Promise.all([
|
? await Promise.all([getDoctorAddresses(doctor.id), getBookingLocations(doctor.uuid)])
|
||||||
getDoctorAddresses(doctor.id),
|
: [[], []];
|
||||||
getBookingLocations(doctor.uuid),
|
|
||||||
getBookingResources(doctor.uuid),
|
|
||||||
])
|
|
||||||
: [[], [], []];
|
|
||||||
|
|
||||||
// آدرسی که در هیچ برنامهٔ نوبتدهی استفاده نشده، محل مراجعه نیست. در کارت
|
// آدرسی که در هیچ برنامهٔ نوبتدهی استفاده نشده، محل مراجعه نیست. در کارت
|
||||||
// «موقعیت مکانی» میماند (اطلاعات واقعی مطب است) ولی به JSON-LD نمیرود، چون
|
// «موقعیت مکانی» میماند (اطلاعات واقعی مطب است) ولی به JSON-LD نمیرود، چون
|
||||||
@@ -273,10 +246,7 @@ async function Doctor({ params }) {
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// همان تخصصی که سربرگ صفحه نشان میدهد؛ اگر اینجا اولین آیتم آرایه میماند،
|
const specialtyLabel = doctor?.specialties?.[0]?.name || "پزشکان";
|
||||||
// BreadcrumbList و نمای صفحه دو چیز متفاوت میگفتند.
|
|
||||||
const specialtyLabel =
|
|
||||||
splitSpecialties(doctor?.specialties).primary?.name || "پزشکان";
|
|
||||||
// مقصد تمیز صفحهٔ فرود تخصص؛ اگر تخصص slug نداشت، به لیست پزشکان برمیگردیم.
|
// مقصد تمیز صفحهٔ فرود تخصص؛ اگر تخصص slug نداشت، به لیست پزشکان برمیگردیم.
|
||||||
const specialtySlug = specialtiesData.find(
|
const specialtySlug = specialtiesData.find(
|
||||||
(item) => item.name === specialtyLabel && item.status === 1
|
(item) => item.name === specialtyLabel && item.status === 1
|
||||||
@@ -345,7 +315,6 @@ async function Doctor({ params }) {
|
|||||||
addresses={addresses}
|
addresses={addresses}
|
||||||
bookableAddressUuids={[...bookableAddressUuids]}
|
bookableAddressUuids={[...bookableAddressUuids]}
|
||||||
bookingLocations={bookingLocations}
|
bookingLocations={bookingLocations}
|
||||||
bookingResources={bookingResources}
|
|
||||||
slug={slug}
|
slug={slug}
|
||||||
faq={faq}
|
faq={faq}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -74,6 +74,60 @@ body {
|
|||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── توکنهای جریان رزرو ──────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* جریان رزرو رنگهایش را hard-code داشت، پس در دارکمود دقیقاً همان سفید میماند.
|
||||||
|
* اینها **رنگ تازه نیستند**: مقادیر روشن عیناً همان چیزی است که تا امروز بود، و
|
||||||
|
* نسخهٔ تیره از همانها مشتق شده. طراحی تازهای اینجا اختراع نشده.
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--ap-surface: #ffffff;
|
||||||
|
--ap-surface-2: #fafafa;
|
||||||
|
--ap-surface-3: #f5f5f5;
|
||||||
|
--ap-border: #efefef;
|
||||||
|
--ap-border-2: #d7d7d7;
|
||||||
|
--ap-heading: #2f2f2f;
|
||||||
|
--ap-text: #3b3b3b;
|
||||||
|
--ap-text-2: #616161;
|
||||||
|
--ap-text-3: #7a7a7a;
|
||||||
|
--ap-text-4: #9b9b9b;
|
||||||
|
--ap-muted: #525252;
|
||||||
|
--ap-primary: #5559ce;
|
||||||
|
--ap-accent: #f17732;
|
||||||
|
--ap-accent-strong: #b4541f;
|
||||||
|
--ap-danger: #e0383b;
|
||||||
|
--ap-danger-bg: #fdeeee;
|
||||||
|
--ap-danger-border: #f3c0c1;
|
||||||
|
--ap-warning: #f4b740;
|
||||||
|
--ap-warning-bg: #fff8e8;
|
||||||
|
--ap-warning-text: #8a6100;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--ap-surface: #1b1b20;
|
||||||
|
--ap-surface-2: #232329;
|
||||||
|
--ap-surface-3: #2a2a31;
|
||||||
|
--ap-border: #2e2e36;
|
||||||
|
--ap-border-2: #3a3a44;
|
||||||
|
--ap-heading: #f0f0f2;
|
||||||
|
--ap-text: #e8e8ea;
|
||||||
|
--ap-text-2: #b8b8be;
|
||||||
|
--ap-text-3: #98989f;
|
||||||
|
--ap-text-4: #82828a;
|
||||||
|
--ap-muted: #c9c9cf;
|
||||||
|
/* بنفش و نارنجی روی زمینهٔ تیره کمکنتراستاند؛ کمی روشنتر شدهاند نه عوض. */
|
||||||
|
--ap-primary: #8a8de6;
|
||||||
|
--ap-accent: #ff9152;
|
||||||
|
--ap-accent-strong: #ffb184;
|
||||||
|
--ap-danger: #ff6b6e;
|
||||||
|
--ap-danger-bg: #3a1f20;
|
||||||
|
--ap-danger-border: #5a2e30;
|
||||||
|
--ap-warning: #ffca5c;
|
||||||
|
--ap-warning-bg: #33290f;
|
||||||
|
--ap-warning-text: #ffd98a;
|
||||||
|
}
|
||||||
|
|
||||||
/* transition-colors only on theme-sensitive elements, not every DOM node */
|
/* transition-colors only on theme-sensitive elements, not every DOM node */
|
||||||
.dark,
|
.dark,
|
||||||
[data-theme] {
|
[data-theme] {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { buildSpecialtyFaq, buildSpecialtyIntro } from "@/lib/specialtyContent";
|
|||||||
import { isCategorySpecialty } from "@/lib/specialtyIndexing";
|
import { isCategorySpecialty } from "@/lib/specialtyIndexing";
|
||||||
import { safeJsonLd } from "@/lib/sanitize";
|
import { safeJsonLd } from "@/lib/sanitize";
|
||||||
import { fetchReq } from "@/lib/req";
|
import { fetchReq } from "@/lib/req";
|
||||||
import { decodeRouteParam } from "@/lib/routeParams";
|
|
||||||
import { isNextRedirectError } from "@/lib/maintenance";
|
import { isNextRedirectError } from "@/lib/maintenance";
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
@@ -43,8 +42,7 @@ const getDoctors = cache(async (specialtyId, cityId) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function generateMetadata({ params }) {
|
export async function generateMetadata({ params }) {
|
||||||
const { slug: rawSlug } = await params;
|
const { slug } = await params;
|
||||||
const slug = decodeRouteParam(rawSlug);
|
|
||||||
const specialty = findSpecialty(slug);
|
const specialty = findSpecialty(slug);
|
||||||
if (!specialty) notFound();
|
if (!specialty) notFound();
|
||||||
|
|
||||||
@@ -75,8 +73,7 @@ export async function generateMetadata({ params }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function SpecialtyDetail({ params }) {
|
async function SpecialtyDetail({ params }) {
|
||||||
const { slug: rawSlug } = await params;
|
const { slug } = await params;
|
||||||
const slug = decodeRouteParam(rawSlug);
|
|
||||||
const specialty = findSpecialty(slug);
|
const specialty = findSpecialty(slug);
|
||||||
if (!specialty) notFound();
|
if (!specialty) notFound();
|
||||||
|
|
||||||
@@ -138,7 +135,6 @@ async function SpecialtyDetail({ params }) {
|
|||||||
/>
|
/>
|
||||||
<Layout name="/specialties">
|
<Layout name="/specialties">
|
||||||
<SpecialtyDetailPage
|
<SpecialtyDetailPage
|
||||||
specialty={specialty}
|
|
||||||
specialtyName={specialty.name}
|
specialtyName={specialty.name}
|
||||||
cityName={cityName}
|
cityName={cityName}
|
||||||
doctors={doctors}
|
doctors={doctors}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
import Image from "next/image";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import SiteLogo from "@/components/common/SiteLogo";
|
|
||||||
|
|
||||||
function Head({ siteName, slogan, city }) {
|
function Head({ siteName, slogan }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#5559C2] rounded-none md:rounded-lg w-full py-[24px] flex flex-col items-center justify-center gap-[13px] md:gap-[16px]">
|
<div className="bg-[#5559C2] rounded-none md:rounded-lg w-full py-[24px] flex flex-col items-center justify-center gap-[13px] md:gap-[16px]">
|
||||||
{/* رنگ پیشفرض لوگو #5559CE است و روی همین پسزمینه گم میشود؛ نسخهٔ روشن. */}
|
<Image
|
||||||
<SiteLogo city={city} stroke="#FAFAFA" className="w-[46px] h-[46px]" />
|
src="/assets/images/logo-2.png"
|
||||||
|
height={46}
|
||||||
|
width={46}
|
||||||
|
alt="logo"
|
||||||
|
/>
|
||||||
<h1 className="text-[#FAFAFA] text-[16px] sm:text-[18px] md:text-[22px] lg:text-[24px] font-bold">
|
<h1 className="text-[#FAFAFA] text-[16px] sm:text-[18px] md:text-[22px] lg:text-[24px] font-bold">
|
||||||
درباره {siteName}
|
درباره {siteName}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import Head from "@/components/aboutUs/Head";
|
|
||||||
|
|
||||||
const CITY = { name: "یاسوج", site_name: "یاسوج نوبت", logo_url: null };
|
|
||||||
|
|
||||||
describe("سربرگ درباره ما", () => {
|
|
||||||
it("شهر بدون logo_url لوگوی درونخطی با رنگ روشن میگیرد", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<Head siteName={CITY.site_name} slogan="کنار شما" city={CITY} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const path = container.querySelector("svg path");
|
|
||||||
|
|
||||||
// پسزمینهٔ هدر #5559C2 است؛ رنگ پیشفرض لوگو روی آن دیده نمیشود.
|
|
||||||
expect(path).toHaveAttribute("stroke", "#FAFAFA");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("شهر دارای logo_url همان تصویر را نشان میدهد", () => {
|
|
||||||
render(
|
|
||||||
<Head
|
|
||||||
siteName={CITY.site_name}
|
|
||||||
city={{ ...CITY, logo_url: "https://cdn.test/logo.png" }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole("img", { name: "logo" })).toHaveAttribute(
|
|
||||||
"src",
|
|
||||||
"https://cdn.test/logo.png"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("لوگوی قدیمی logo-2.png دیگر رندر نمیشود", () => {
|
|
||||||
const { container } = render(<Head siteName={CITY.site_name} city={CITY} />);
|
|
||||||
|
|
||||||
expect(container.innerHTML).not.toContain("logo-2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("شعار نداشتن، سربرگ را نمیشکند", () => {
|
|
||||||
render(<Head siteName={CITY.site_name} city={CITY} />);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByRole("heading", { name: `درباره ${CITY.site_name}` })
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,27 +1,20 @@
|
|||||||
import { getStateInfo } from "@/lib/getStateInfo";
|
import { getStateInfo } from "@/lib/getStateInfo";
|
||||||
import { buildAboutIntro } from "@/lib/staticPageIntro";
|
|
||||||
import Head from "./Head";
|
import Head from "./Head";
|
||||||
import Services from "./Services";
|
import Services from "./Services";
|
||||||
|
|
||||||
async function AboutUsPage() {
|
async function AboutUsPage() {
|
||||||
const { matchedCity, matchedState, isRoot } = await getStateInfo();
|
const { matchedCity } = await getStateInfo();
|
||||||
const siteName = matchedCity?.site_name || "نوبت ۷۲۴";
|
const siteName = matchedCity?.site_name || "نوبت ۷۲۴";
|
||||||
const slogan = matchedCity?.slogan;
|
const slogan = matchedCity?.slogan;
|
||||||
// این صفحه self-canonical است؛ بدون متن شهری، ۳۵ دامنه یک محتوا اعلام میکردند.
|
|
||||||
// دامنهٔ ریشه شهر نیست، پس متن شهری نمیگیرد.
|
|
||||||
const cityIntro = isRoot
|
|
||||||
? ""
|
|
||||||
: buildAboutIntro(matchedCity?.name, matchedState?.name, siteName, slogan);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className=" pt-[24px] sm:pt-[56px] md:pt-[88px] lg:pt-[120px] mt-[56px] px-0 md:px-[80px] lg:px-[108px] "
|
className=" pt-[24px] sm:pt-[56px] md:pt-[88px] lg:pt-[120px] mt-[56px] px-0 md:px-[80px] lg:px-[108px] "
|
||||||
>
|
>
|
||||||
<Head siteName={siteName} slogan={slogan} city={matchedCity} />
|
<Head siteName={siteName} slogan={slogan} />
|
||||||
<div
|
<div
|
||||||
className=" text-[#525252] text-[16px] font-normal mt-[32px] leading-[28px] md:leading-[32px] lg:leading-[40px] px-[16px] sm:px-[52px] md:px-[15%] text-justify md:text-center flex flex-col gap-[16px] "
|
className=" text-[#525252] text-[16px] font-normal mt-[32px] leading-[28px] md:leading-[32px] lg:leading-[40px] px-[16px] sm:px-[52px] md:px-[15%] text-justify md:text-center flex flex-col gap-[16px] "
|
||||||
>
|
>
|
||||||
{cityIntro && <p>{cityIntro}</p>}
|
|
||||||
<p>
|
<p>
|
||||||
{siteName} یک سامانهی آنلاین نوبتدهی پزشکی است که با هدف سادهتر کردن
|
{siteName} یک سامانهی آنلاین نوبتدهی پزشکی است که با هدف سادهتر کردن
|
||||||
دسترسی مردم به خدمات سلامت طراحی شده است. ما تلاش میکنیم تجربهی رزرو
|
دسترسی مردم به خدمات سلامت طراحی شده است. ما تلاش میکنیم تجربهی رزرو
|
||||||
@@ -34,22 +27,6 @@ async function AboutUsPage() {
|
|||||||
نوبت خود را ثبت کنید. هدف ما کاهش زمان انتظار و ایجاد ارتباط مؤثر میان
|
نوبت خود را ثبت کنید. هدف ما کاهش زمان انتظار و ایجاد ارتباط مؤثر میان
|
||||||
بیماران، پزشکان و کلینیکهاست.
|
بیماران، پزشکان و کلینیکهاست.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
|
||||||
اگر پزشک هستید و میخواهید در {siteName} نوبتدهی آنلاین داشته باشید،
|
|
||||||
ثبتنام از طریق سامانهی کلینیکپرو انجام میشود. حساب مطب خود را
|
|
||||||
آنجا بسازید؛ پس از تأیید، پروفایل و برنامهی نوبتدهی شما روی{" "}
|
|
||||||
{siteName} نمایش داده میشود.
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-center mt-[8px]">
|
|
||||||
<a
|
|
||||||
href="https://clinic-pro.ir/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="px-[24px] py-[12px] rounded-lg bg-[#5559CE] border border-[#5559CE] text-[#FFF] text-[14px] md:text-[16px] font-medium hover:bg-[#FFF] hover:text-[#5559CE] transition-all duration-500"
|
|
||||||
>
|
|
||||||
ثبتنام پزشکان در کلینیکپرو
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Services />
|
<Services />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Date from "./date";
|
import Date from "./date";
|
||||||
import ServiceSelect from "./service";
|
import ServiceSelect from "./service";
|
||||||
import ResourceSelect from "./resource";
|
|
||||||
import LocationSelect from "./location/LocationSelect";
|
import LocationSelect from "./location/LocationSelect";
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
@@ -54,31 +53,15 @@ function Container({
|
|||||||
reopenLocationChoice,
|
reopenLocationChoice,
|
||||||
onDateChange,
|
onDateChange,
|
||||||
selectedClosedOnDate,
|
selectedClosedOnDate,
|
||||||
// نوبتدهی منبعمحور (دستگاه / اتاق / یونیت)
|
|
||||||
resources = [],
|
|
||||||
selectedResource = null,
|
|
||||||
resourceStepNeeded = false,
|
|
||||||
changeResource,
|
|
||||||
reopenResourceChoice,
|
|
||||||
}) {
|
}) {
|
||||||
const serviceMode = bookingMode === "service";
|
const serviceMode = bookingMode === "service";
|
||||||
|
const clinicUuid = selectedLocation?.clinic_uuid ?? null;
|
||||||
const multiLocation = bookingLocations.length > 1;
|
const multiLocation = bookingLocations.length > 1;
|
||||||
const resourceUuid = selectedResource?.uuid ?? null;
|
|
||||||
|
|
||||||
// محیطِ رزرو از خودِ منبع خوانده میشود وقتی منبعی انتخاب شده: پزشکِ بدون برنامهٔ
|
|
||||||
// هفتگی هیچ محلی ندارد که `clinic_uuid` از آن بیاید، ولی دستگاهش محیطش را میداند.
|
|
||||||
const clinicUuid = selectedResource
|
|
||||||
? selectedResource.clinic_uuid ?? null
|
|
||||||
: selectedLocation?.clinic_uuid ?? null;
|
|
||||||
|
|
||||||
// «محل ندارد» دیگر به معنی «نوبتدهی ندارد» نیست: منبع تقویم خودش را دارد، پس پزشکی
|
|
||||||
// که برنامهٔ هفتگی ندارد ولی دستگاهش قابل رزرو است باید به مرحلهٔ منبع برسد.
|
|
||||||
const noBookingAtAll = bookingLocations.length === 0 && resources.length === 0;
|
|
||||||
|
|
||||||
let dateStep;
|
let dateStep;
|
||||||
if (noBookingAtAll) {
|
if (bookingLocations.length === 0) {
|
||||||
dateStep = (
|
dateStep = (
|
||||||
<p className="w-full max-w-[520px] mx-auto py-[40px] text-center text-[14px] text-[#7A7A7A]">
|
<p className="w-full max-w-[520px] mx-auto py-[40px] text-center text-[14px] text-[var(--ap-text-3)]">
|
||||||
نوبتدهی آنلاین برای این پزشک فعال نیست.
|
نوبتدهی آنلاین برای این پزشک فعال نیست.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@@ -90,19 +73,12 @@ function Container({
|
|||||||
onSelect={changeLocation}
|
onSelect={changeLocation}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (resourceStepNeeded) {
|
|
||||||
dateStep = (
|
|
||||||
<ResourceSelect
|
|
||||||
resources={resources}
|
|
||||||
selected={selectedResource}
|
|
||||||
onSelect={changeResource}
|
|
||||||
allowDoctorOption={bookingLocations.length > 0}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (serviceMode && selectedServiceUuids.length === 0) {
|
} else if (serviceMode && selectedServiceUuids.length === 0) {
|
||||||
dateStep = (
|
dateStep = (
|
||||||
<ServiceSelect
|
<ServiceSelect
|
||||||
services={bookingServices}
|
services={bookingServices}
|
||||||
|
doctorUuid={doctor?.uuid}
|
||||||
|
clinicUuid={clinicUuid}
|
||||||
onContinue={(uuids) => setSelectedServiceUuids(uuids)}
|
onContinue={(uuids) => setSelectedServiceUuids(uuids)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -119,9 +95,7 @@ function Container({
|
|||||||
onChangeService={() => setSelectedServiceUuids([])}
|
onChangeService={() => setSelectedServiceUuids([])}
|
||||||
selectedLocation={selectedLocation}
|
selectedLocation={selectedLocation}
|
||||||
clinicUuid={clinicUuid}
|
clinicUuid={clinicUuid}
|
||||||
resourceUuid={resourceUuid}
|
|
||||||
onChangeLocation={multiLocation ? reopenLocationChoice : null}
|
onChangeLocation={multiLocation ? reopenLocationChoice : null}
|
||||||
onChangeResource={resources.length > 0 ? reopenResourceChoice : null}
|
|
||||||
onDateChange={onDateChange}
|
onDateChange={onDateChange}
|
||||||
closedOnDate={selectedClosedOnDate}
|
closedOnDate={selectedClosedOnDate}
|
||||||
/>
|
/>
|
||||||
@@ -162,7 +136,6 @@ function Container({
|
|||||||
setAppointmentExpiresAt={setAppointmentExpiresAt}
|
setAppointmentExpiresAt={setAppointmentExpiresAt}
|
||||||
selectedServiceUuids={selectedServiceUuids}
|
selectedServiceUuids={selectedServiceUuids}
|
||||||
clinicUuid={clinicUuid}
|
clinicUuid={clinicUuid}
|
||||||
resourceUuid={resourceUuid}
|
|
||||||
/>,
|
/>,
|
||||||
<Paying
|
<Paying
|
||||||
setStep={setStep}
|
setStep={setStep}
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
|
|
||||||
// جریان نوبت به کوکی، تم، تقویم و لایهی API وابسته است؛ اینجا فقط تصمیمِ
|
|
||||||
// «کدام مرحله رندر میشود» سنجیده میشود، پس بقیه mock میشوند.
|
|
||||||
vi.mock('@/components/layout', () => ({ default: ({ children }) => <div>{children}</div> }));
|
|
||||||
vi.mock('./Content', () => ({ default: ({ children }) => <div>{children}</div> }));
|
|
||||||
vi.mock('./date', () => ({ default: () => <div>مرحله-روز</div> }));
|
|
||||||
vi.mock('@/components/appointment/paying', () => ({ default: () => null }));
|
|
||||||
vi.mock('@/components/appointment/detail', () => ({ default: () => null }));
|
|
||||||
vi.mock('@/components/appointment/failedPay', () => ({ default: () => null }));
|
|
||||||
vi.mock('@/components/appointment/successPay', () => ({ default: () => null }));
|
|
||||||
vi.mock('@/components/register/LogInPage', () => ({ default: () => null }));
|
|
||||||
vi.mock('@/components/register/verificationPage', () => ({ default: () => null }));
|
|
||||||
vi.mock('@/components/register/LayoutRegister', () => ({ default: ({ children }) => <div>{children}</div> }));
|
|
||||||
|
|
||||||
import Container from './Container';
|
|
||||||
|
|
||||||
const RESOURCE = {
|
|
||||||
uuid: 'r1',
|
|
||||||
name: 'کندلا2021',
|
|
||||||
type: { name: 'دستگاه لیزر' },
|
|
||||||
services: [{ uuid: 's1', name: 'لیزر دست', duration_minutes: 20 }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const LOCATION = { clinic_uuid: null, title: 'مطب شخصی', booking_mode: 'slot', services: [] };
|
|
||||||
|
|
||||||
const props = (over = {}) => ({
|
|
||||||
step: 0,
|
|
||||||
data: {},
|
|
||||||
doctor: { uuid: 'd1' },
|
|
||||||
setData: vi.fn(),
|
|
||||||
setStep: vi.fn(),
|
|
||||||
prevData: {},
|
|
||||||
matchedCity: {},
|
|
||||||
isForAnother: false,
|
|
||||||
setIsForAnother: vi.fn(),
|
|
||||||
disabledDates: [],
|
|
||||||
setNum: vi.fn(),
|
|
||||||
setUuid: vi.fn(),
|
|
||||||
setIsSendMsg: vi.fn(),
|
|
||||||
setSelectedSlot: vi.fn(),
|
|
||||||
setSelectedDate: vi.fn(),
|
|
||||||
setAppointmentId: vi.fn(),
|
|
||||||
setAppointmentExpiresAt: vi.fn(),
|
|
||||||
bookingMode: 'slot',
|
|
||||||
bookingServices: [],
|
|
||||||
selectedServiceUuids: [],
|
|
||||||
setSelectedServiceUuids: vi.fn(),
|
|
||||||
bookingLocations: [LOCATION],
|
|
||||||
selectedLocation: LOCATION,
|
|
||||||
locationConfirmed: true,
|
|
||||||
changeLocation: vi.fn(),
|
|
||||||
reopenLocationChoice: vi.fn(),
|
|
||||||
onDateChange: vi.fn(),
|
|
||||||
selectedClosedOnDate: false,
|
|
||||||
resources: [],
|
|
||||||
selectedResource: null,
|
|
||||||
resourceStepNeeded: false,
|
|
||||||
changeResource: vi.fn(),
|
|
||||||
reopenResourceChoice: vi.fn(),
|
|
||||||
...over,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('مرحلهٔ انتخاب نوع نوبت در جریان رزرو', () => {
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
|
||||||
|
|
||||||
it('پزشکِ بدون منبع → هیچ تغییری؛ مستقیم مرحلهٔ روز', () => {
|
|
||||||
render(<Container {...props()} />);
|
|
||||||
|
|
||||||
expect(screen.getByText('مرحله-روز')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('انتخاب نوع نوبت')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('منبع هست و هنوز انتخاب نشده → مرحلهٔ نوع نوبت', () => {
|
|
||||||
render(<Container {...props({ resources: [RESOURCE], resourceStepNeeded: true })} />);
|
|
||||||
|
|
||||||
expect(screen.getByText('۱. انتخاب نوع نوبت')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('کندلا2021')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('مرحله-روز')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('محلِ تأییدنشده مقدم بر مرحلهٔ منبع است', () => {
|
|
||||||
render(
|
|
||||||
<Container
|
|
||||||
{...props({
|
|
||||||
bookingLocations: [LOCATION, { ...LOCATION, clinic_uuid: 'c2', title: 'کلینیک' }],
|
|
||||||
locationConfirmed: false,
|
|
||||||
resources: [RESOURCE],
|
|
||||||
resourceStepNeeded: true,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('۱. انتخاب محل نوبتدهی')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('۱. انتخاب نوع نوبت')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('پزشکِ بدون برنامهٔ هفتگی ولی دارای منبع → مرحلهٔ منبع، نه پیام «فعال نیست»', () => {
|
|
||||||
render(
|
|
||||||
<Container
|
|
||||||
{...props({
|
|
||||||
bookingLocations: [],
|
|
||||||
selectedLocation: null,
|
|
||||||
resources: [{ ...RESOURCE, clinic_uuid: 'c5' }],
|
|
||||||
resourceStepNeeded: true,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('۱. انتخاب نوع نوبت')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('کندلا2021')).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.queryByText('نوبتدهی آنلاین برای این پزشک فعال نیست.')
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
// ویزیت مستقیم وجود ندارد، پس کارتش هم نباید باشد.
|
|
||||||
expect(screen.queryByText('نوبت با پزشک')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('نه محل و نه منبع → همان پیام «فعال نیست»', () => {
|
|
||||||
render(<Container {...props({ bookingLocations: [], selectedLocation: null })} />);
|
|
||||||
|
|
||||||
expect(screen.getByText('نوبتدهی آنلاین برای این پزشک فعال نیست.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('بعد از انتخاب منبع، مرحلهٔ سرویس با سرویسهای همان منبع میآید', () => {
|
|
||||||
render(
|
|
||||||
<Container
|
|
||||||
{...props({
|
|
||||||
resources: [RESOURCE],
|
|
||||||
selectedResource: RESOURCE,
|
|
||||||
resourceStepNeeded: false,
|
|
||||||
bookingMode: 'service',
|
|
||||||
bookingServices: RESOURCE.services,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('۱. انتخاب سرویس')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('لیزر دست')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
import DatePicker from "@/app/component/date/datePicker";
|
import DatePicker from "@/app/component/date/datePicker";
|
||||||
|
|
||||||
function SelectDatePicker({ setDate, disabledDates, clinicUuid, resourceUuid = null, selectedServiceUuids = [] }) {
|
function SelectDatePicker({ setDate, disabledDates, clinicUuid }) {
|
||||||
return (
|
return <DatePicker setDate={setDate} disabledDates={disabledDates} clinicUuid={clinicUuid} />;
|
||||||
<DatePicker
|
|
||||||
setDate={setDate}
|
|
||||||
disabledDates={disabledDates}
|
|
||||||
clinicUuid={clinicUuid}
|
|
||||||
resourceUuid={resourceUuid}
|
|
||||||
selectedServiceUuids={selectedServiceUuids}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SelectDatePicker;
|
export default SelectDatePicker;
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import SelectDatePicker from "./SelectDatePicker";
|
import SelectDatePicker from "./SelectDatePicker";
|
||||||
|
|
||||||
function Time({ isStep, setDate, disabledDates, clinicUuid, resourceUuid = null, selectedServiceUuids = [] }) {
|
function Time({ isStep, setDate, disabledDates, clinicUuid }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex relative mt-[24px] flex-col items-start gap-[19px] justify-start">
|
<div className="flex relative mt-[24px] flex-col items-start gap-[19px] justify-start">
|
||||||
<p className="text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
|
<p className="text-[var(--ap-text)] text-[14px] md:text-[16px] font-bold">
|
||||||
1. انتخاب روز
|
1. انتخاب روز
|
||||||
</p>
|
</p>
|
||||||
<SelectDatePicker
|
<SelectDatePicker setDate={setDate} disabledDates={disabledDates} clinicUuid={clinicUuid} />
|
||||||
setDate={setDate}
|
|
||||||
disabledDates={disabledDates}
|
|
||||||
clinicUuid={clinicUuid}
|
|
||||||
resourceUuid={resourceUuid}
|
|
||||||
selectedServiceUuids={selectedServiceUuids}
|
|
||||||
/>
|
|
||||||
{!isStep && (
|
{!isStep && (
|
||||||
<div className="absolute left-0 top-0 w-full h-full bg-[rgba(255,255,255,0.84)]"></div>
|
<div className="absolute left-0 top-0 w-full h-full bg-[rgba(255,255,255,0.84)]"></div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import DateTime from "@/app/component/date/dateTime";
|
import DateTime from "@/app/component/date/dateTime";
|
||||||
|
|
||||||
function Hour({ doctor, setStep, date, isStep, setSelectedSlot, setSelectedDate, serviceMode = false, selectedServiceUuids = [], clinicUuid = null, resourceUuid = null, selectedLocation = null }) {
|
function Hour({ doctor, setStep, date, isStep, setSelectedSlot, setSelectedDate, serviceMode = false, selectedServiceUuids = [], clinicUuid = null, selectedLocation = null }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex relative mt-[24px] flex-col items-start gap-[12px] justify-start">
|
<div className="flex relative mt-[24px] flex-col items-start gap-[12px] justify-start">
|
||||||
<p className="text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
|
<p className="text-[var(--ap-text)] text-[14px] md:text-[16px] font-bold">
|
||||||
2. انتخاب ساعت
|
2. انتخاب ساعت
|
||||||
</p>
|
</p>
|
||||||
<DateTime
|
<DateTime
|
||||||
@@ -15,7 +15,6 @@ function Hour({ doctor, setStep, date, isStep, setSelectedSlot, setSelectedDate,
|
|||||||
serviceMode={serviceMode}
|
serviceMode={serviceMode}
|
||||||
selectedServiceUuids={selectedServiceUuids}
|
selectedServiceUuids={selectedServiceUuids}
|
||||||
clinicUuid={clinicUuid}
|
clinicUuid={clinicUuid}
|
||||||
resourceUuid={resourceUuid}
|
|
||||||
selectedLocation={selectedLocation}
|
selectedLocation={selectedLocation}
|
||||||
/>
|
/>
|
||||||
{!isStep && (
|
{!isStep && (
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ function Date({
|
|||||||
onChangeService,
|
onChangeService,
|
||||||
selectedLocation = null,
|
selectedLocation = null,
|
||||||
clinicUuid = null,
|
clinicUuid = null,
|
||||||
resourceUuid = null,
|
|
||||||
onChangeLocation,
|
onChangeLocation,
|
||||||
onChangeResource,
|
|
||||||
onDateChange,
|
onDateChange,
|
||||||
closedOnDate = false,
|
closedOnDate = false,
|
||||||
}) {
|
}) {
|
||||||
@@ -29,33 +27,24 @@ function Date({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full lg:w-[61%]">
|
<div className="w-full lg:w-[61%]">
|
||||||
<div
|
<div
|
||||||
className="p-0 lg:p-[24px] rounded-[8px] border border-solid border-transparent lg:border-[#EFEFEF] bg-transparent lg:bg-[#FFF]"
|
className="p-0 lg:p-[24px] rounded-[8px] border border-solid border-transparent lg:border-[var(--ap-border)] bg-transparent lg:bg-[var(--ap-surface)]"
|
||||||
>
|
>
|
||||||
{(onChangeLocation || onChangeResource || (serviceMode && onChangeService)) && (
|
{(onChangeLocation || (serviceMode && onChangeService)) && (
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1">
|
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||||
{onChangeLocation && (
|
{onChangeLocation && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onChangeLocation}
|
onClick={onChangeLocation}
|
||||||
className="text-[13px] text-[#5559CE] hover:underline"
|
className="text-[13px] text-[var(--ap-primary)] hover:underline"
|
||||||
>
|
>
|
||||||
← تغییر محل{selectedLocation?.title ? ` (${selectedLocation.title})` : ""}
|
← تغییر محل{selectedLocation?.title ? ` (${selectedLocation.title})` : ""}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onChangeResource && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onChangeResource}
|
|
||||||
className="text-[13px] text-[#5559CE] hover:underline"
|
|
||||||
>
|
|
||||||
← تغییر نوع نوبت
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{serviceMode && onChangeService && (
|
{serviceMode && onChangeService && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onChangeService}
|
onClick={onChangeService}
|
||||||
className="text-[13px] text-[#5559CE] hover:underline"
|
className="text-[13px] text-[var(--ap-primary)] hover:underline"
|
||||||
>
|
>
|
||||||
← تغییر سرویس
|
← تغییر سرویس
|
||||||
</button>
|
</button>
|
||||||
@@ -63,14 +52,14 @@ function Date({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{closedOnDate && (
|
{closedOnDate && (
|
||||||
<div className="mb-3 rounded-[8px] border border-solid border-[#F17732] bg-[rgba(241,119,50,0.08)] p-[12px]">
|
<div className="mb-3 rounded-[8px] border border-solid border-[var(--ap-accent)] bg-[rgba(241,119,50,0.08)] p-[12px]">
|
||||||
<p className="text-[13px] text-[#B4541F] leading-relaxed">
|
<p className="text-[13px] text-[var(--ap-accent-strong)] leading-relaxed">
|
||||||
«{selectedLocation?.title}» در روز انتخابشده نوبتدهی ندارد.
|
«{selectedLocation?.title}» در روز انتخابشده نوبتدهی ندارد.
|
||||||
{onChangeLocation && (
|
{onChangeLocation && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onChangeLocation}
|
onClick={onChangeLocation}
|
||||||
className="mr-1 font-medium text-[#5559CE] hover:underline"
|
className="mr-1 font-medium text-[var(--ap-primary)] hover:underline"
|
||||||
>
|
>
|
||||||
انتخاب محل دیگر
|
انتخاب محل دیگر
|
||||||
</button>
|
</button>
|
||||||
@@ -83,8 +72,6 @@ function Date({
|
|||||||
isStep
|
isStep
|
||||||
disabledDates={disabledDates}
|
disabledDates={disabledDates}
|
||||||
clinicUuid={clinicUuid}
|
clinicUuid={clinicUuid}
|
||||||
resourceUuid={resourceUuid}
|
|
||||||
selectedServiceUuids={selectedServiceUuids}
|
|
||||||
/>
|
/>
|
||||||
<Hour
|
<Hour
|
||||||
isStep
|
isStep
|
||||||
@@ -96,7 +83,6 @@ function Date({
|
|||||||
serviceMode={serviceMode}
|
serviceMode={serviceMode}
|
||||||
selectedServiceUuids={selectedServiceUuids}
|
selectedServiceUuids={selectedServiceUuids}
|
||||||
clinicUuid={clinicUuid}
|
clinicUuid={clinicUuid}
|
||||||
resourceUuid={resourceUuid}
|
|
||||||
selectedLocation={selectedLocation}
|
selectedLocation={selectedLocation}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ function Content({ isConfirmed, children, title }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col items-start justify-start gap-[8px] sm:gap-[10px] md:gap-[13px] lg:gap-[16px]">
|
<div className="flex w-full flex-col items-start justify-start gap-[8px] sm:gap-[10px] md:gap-[13px] lg:gap-[16px]">
|
||||||
<div className="flex min-h-[32px] items-center justify-start gap-[8px] md:gap-[6px] lg:gap-[4px]">
|
<div className="flex min-h-[32px] items-center justify-start gap-[8px] md:gap-[6px] lg:gap-[4px]">
|
||||||
<p className="text-[#525252] text-[14px] font-medium">{title}</p>
|
<p className="text-[var(--ap-muted)] text-[14px] font-medium">{title}</p>
|
||||||
{isConfirmed && (
|
{isConfirmed && (
|
||||||
<p className="py-[4px] md:py-[2px] leading-[8px] sm:leading-[14px] md:leading-[21px] lg:leading-[28px] px-[5px] bg-[#05BA58] rounded-[40px] text-[#FFF] text-[10px] md:text-[12px] font-normal">
|
<p className="py-[4px] md:py-[2px] leading-[8px] sm:leading-[14px] md:leading-[21px] lg:leading-[28px] px-[5px] bg-[#05BA58] rounded-[40px] text-[var(--ap-surface)] text-[10px] md:text-[12px] font-normal">
|
||||||
تایید شده
|
تایید شده
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ function Form({ changeData, insurance, supplementaryInsurance, data, isForAnothe
|
|||||||
{isForAnother ? (
|
{isForAnother ? (
|
||||||
<EditField changeData={changeData} data={data?.phone} name="phone" error={errors?.phone} />
|
<EditField changeData={changeData} data={data?.phone} name="phone" error={errors?.phone} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between px-[14px] py-[12.5px] border border-[#D7D7D7] rounded-lg bg-[#F5F5F5]">
|
<div className="flex items-center justify-between px-[14px] py-[12.5px] border border-[var(--ap-border-2)] rounded-lg bg-[var(--ap-surface-3)]">
|
||||||
<span className="text-[#3B3B3B]">{data?.phone?.value}</span>
|
<span className="text-[var(--ap-text)]">{data?.phone?.value}</span>
|
||||||
<span className="text-[#4CAF50] text-[14px]">تایید شده</span>
|
<span className="text-[#4CAF50] text-[14px]">تایید شده</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Cookies from "js-cookie";
|
|||||||
import { setAccessToken } from "@/lib/tokenStore";
|
import { setAccessToken } from "@/lib/tokenStore";
|
||||||
import { useProvince } from "@/context/ProvinceProvider";
|
import { useProvince } from "@/context/ProvinceProvider";
|
||||||
|
|
||||||
function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt, selectedServiceUuids = [], clinicUuid = null, resourceUuid = null }) {
|
function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt, selectedServiceUuids = [], clinicUuid = null }) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { cityId } = useProvince();
|
const { cityId } = useProvince();
|
||||||
const newStep = () => setStep((prev) => prev + 1);
|
const newStep = () => setStep((prev) => prev + 1);
|
||||||
@@ -150,9 +150,6 @@ function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother,
|
|||||||
city_id: cityId ?? null,
|
city_id: cityId ?? null,
|
||||||
// حالت نوبتدهی سرویسی: backend مدت و slot_end را از این سرویسها بازمحاسبه میکند.
|
// حالت نوبتدهی سرویسی: backend مدت و slot_end را از این سرویسها بازمحاسبه میکند.
|
||||||
...(selectedServiceUuids?.length ? { service_item_uuids: selectedServiceUuids } : {}),
|
...(selectedServiceUuids?.length ? { service_item_uuids: selectedServiceUuids } : {}),
|
||||||
// نوبت منبعمحور: مدت از زنجیرهٔ حلِ همین منبع بازمحاسبه میشود، پس
|
|
||||||
// slot_end فرستادهشده فقط پیشنهاد است.
|
|
||||||
...(resourceUuid ? { resource_uuid: resourceUuid } : {}),
|
|
||||||
...(isForAnother
|
...(isForAnother
|
||||||
? {
|
? {
|
||||||
patient_name: [data?.name?.value, data?.family?.value].filter(Boolean).join(" ").trim(),
|
patient_name: [data?.name?.value, data?.family?.value].filter(Boolean).join(" ").trim(),
|
||||||
@@ -198,7 +195,7 @@ function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother,
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className={`${loading && "opacity-0"} text-[#EFEFEF] text-[16px] font-medium`}
|
className={`${loading && "opacity-0"} text-[var(--ap-border)] text-[16px] font-medium`}
|
||||||
>
|
>
|
||||||
ثبت اطلاعات
|
ثبت اطلاعات
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
|
|
||||||
vi.mock('@/services/response', () => ({
|
|
||||||
request: {
|
|
||||||
postAppointment: vi.fn(() => Promise.resolve({ data: { data: { uuid: 'a1' } } })),
|
|
||||||
patchUserProfile: vi.fn(() => Promise.resolve({})),
|
|
||||||
postUserProfile: vi.fn(() => Promise.resolve({})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock('@/context/ProvinceProvider', () => ({ useProvince: () => ({ cityId: 7 }) }));
|
|
||||||
vi.mock('js-cookie', () => ({ default: { get: () => undefined, set: vi.fn() } }));
|
|
||||||
vi.mock('@/lib/tokenStore', () => ({ setAccessToken: vi.fn() }));
|
|
||||||
vi.mock('../paying/ButtonFixed', () => ({ default: ({ children }) => <div>{children}</div> }));
|
|
||||||
vi.mock('@/components/icons/ArrowLeftB', () => ({ default: () => null }));
|
|
||||||
|
|
||||||
import { request } from '@/services/response';
|
|
||||||
import SubmitData from './SubmitData';
|
|
||||||
|
|
||||||
const VALID = {
|
|
||||||
uuid: 'p1',
|
|
||||||
phone: { value: '09120000000' },
|
|
||||||
national_code: { value: '0012345678' },
|
|
||||||
name: { value: 'علی' },
|
|
||||||
family: { value: 'رضایی' },
|
|
||||||
gender: { value: 'male' },
|
|
||||||
basic_insurance: { value: { id: 3 } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const props = (over = {}) => ({
|
|
||||||
setStep: vi.fn(),
|
|
||||||
data: VALID,
|
|
||||||
prevData: VALID,
|
|
||||||
setErrors: vi.fn(),
|
|
||||||
doctor: { uuid: 'doc-1' },
|
|
||||||
isForAnother: false,
|
|
||||||
selectedSlot: { start: 1786339800, end: 1786341000 },
|
|
||||||
selectedDate: 1786339800,
|
|
||||||
setAppointmentId: vi.fn(),
|
|
||||||
setAppointmentExpiresAt: vi.fn(),
|
|
||||||
selectedServiceUuids: ['s1'],
|
|
||||||
clinicUuid: null,
|
|
||||||
...over,
|
|
||||||
});
|
|
||||||
|
|
||||||
const submit = async (over) => {
|
|
||||||
render(<SubmitData {...props(over)} />);
|
|
||||||
await userEvent.click(screen.getByText('ثبت اطلاعات'));
|
|
||||||
return request.postAppointment.mock.calls[0]?.[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('payload ثبت نوبت', () => {
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
|
||||||
|
|
||||||
it('نوبت با پزشک → بدون resource_uuid', async () => {
|
|
||||||
const payload = await submit();
|
|
||||||
|
|
||||||
expect(payload).toBeDefined();
|
|
||||||
expect(payload).not.toHaveProperty('resource_uuid');
|
|
||||||
expect(payload.service_item_uuids).toEqual(['s1']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('نوبت منبعمحور → resource_uuid فرستاده میشود', async () => {
|
|
||||||
const payload = await submit({ resourceUuid: 'r1' });
|
|
||||||
|
|
||||||
expect(payload.resource_uuid).toBe('r1');
|
|
||||||
expect(payload.doctor_uuid).toBe('doc-1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -22,7 +22,6 @@ function Detail({
|
|||||||
setAppointmentExpiresAt,
|
setAppointmentExpiresAt,
|
||||||
selectedServiceUuids = [],
|
selectedServiceUuids = [],
|
||||||
clinicUuid = null,
|
clinicUuid = null,
|
||||||
resourceUuid = null,
|
|
||||||
}) {
|
}) {
|
||||||
const [insurance, setInsurance] = useState();
|
const [insurance, setInsurance] = useState();
|
||||||
const [supplementaryInsurance, setSupplementaryInsurance] = useState();
|
const [supplementaryInsurance, setSupplementaryInsurance] = useState();
|
||||||
@@ -79,10 +78,10 @@ function Detail({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// ${typePay === 'success' || typePay === 'failed' ? 'bg-transparent border-transparent md:bg-[#FFF] md:border-[#EFEFEF]' : ''}
|
// ${typePay === 'success' || typePay === 'failed' ? 'bg-transparent border-transparent md:bg-[var(--ap-surface)] md:border-[var(--ap-border)]' : ''}
|
||||||
<div className="w-full lg:w-[59%]">
|
<div className="w-full lg:w-[59%]">
|
||||||
<div
|
<div
|
||||||
className={`opacity-page bg-[#FFF] border border-solid border-[#EFEFEF] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px] `}
|
className={`opacity-page bg-[var(--ap-surface)] border border-solid border-[var(--ap-border)] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px] `}
|
||||||
>
|
>
|
||||||
{/* دکمه برگشت */}
|
{/* دکمه برگشت */}
|
||||||
<div
|
<div
|
||||||
@@ -92,7 +91,7 @@ function Detail({
|
|||||||
<ArrowRightS />
|
<ArrowRightS />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[#3B3B3B] text-[20px] font-bold">
|
<p className="text-[var(--ap-text)] text-[20px] font-bold">
|
||||||
{isForAnother ? "اطلاعات کاربر جدید" : "اطلاعات حساب کاربری"}
|
{isForAnother ? "اطلاعات کاربر جدید" : "اطلاعات حساب کاربری"}
|
||||||
</p>
|
</p>
|
||||||
<Form
|
<Form
|
||||||
@@ -108,7 +107,7 @@ function Detail({
|
|||||||
onClick={() => (isForAnother ? switchToSelf() : switchToAnother())}
|
onClick={() => (isForAnother ? switchToSelf() : switchToAnother())}
|
||||||
>
|
>
|
||||||
<AddCircleBlueA />
|
<AddCircleBlueA />
|
||||||
<p className="text-[#5559CE] text-[16px] font-medium">
|
<p className="text-[var(--ap-primary)] text-[16px] font-medium">
|
||||||
{isForAnother ? "نوبت برای خودم است" : "نوبت برای شخص دیگری است"}
|
{isForAnother ? "نوبت برای خودم است" : "نوبت برای شخص دیگری است"}
|
||||||
</p>
|
</p>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -125,7 +124,6 @@ function Detail({
|
|||||||
setAppointmentExpiresAt={setAppointmentExpiresAt}
|
setAppointmentExpiresAt={setAppointmentExpiresAt}
|
||||||
selectedServiceUuids={selectedServiceUuids}
|
selectedServiceUuids={selectedServiceUuids}
|
||||||
clinicUuid={clinicUuid}
|
clinicUuid={clinicUuid}
|
||||||
resourceUuid={resourceUuid}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function FailedPay({ setStep, fadeElement }) {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full lg:w-[59%]">
|
<div className="w-full lg:w-[59%]">
|
||||||
<div
|
<div
|
||||||
className={`opacity-page bg-transparent lg:bg-[#FFF] border border-solid border-transparent lg:border-[#EFEFEF] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
|
className={`opacity-page bg-transparent lg:bg-[var(--ap-surface)] border border-solid border-transparent lg:border-[var(--ap-border)] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
|
||||||
>
|
>
|
||||||
<div className="mt-[7px]">
|
<div className="mt-[7px]">
|
||||||
<div className="flex flex-col justify-center items-center gap-[14px]">
|
<div className="flex flex-col justify-center items-center gap-[14px]">
|
||||||
@@ -24,7 +24,7 @@ function FailedPay({ setStep, fadeElement }) {
|
|||||||
className="!flex !rounded-[8px] !items-center !p-2 !justify-center !gap-[4px]"
|
className="!flex !rounded-[8px] !items-center !p-2 !justify-center !gap-[4px]"
|
||||||
>
|
>
|
||||||
<RedoA />
|
<RedoA />
|
||||||
<p className="text-[#5559CE] text-[16px] font-medium">
|
<p className="text-[var(--ap-primary)] text-[16px] font-medium">
|
||||||
صفحه اصلی
|
صفحه اصلی
|
||||||
</p>
|
</p>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -35,7 +35,7 @@ function FailedPay({ setStep, fadeElement }) {
|
|||||||
className="!flex !rounded-[8px] !items-center !p-2 !justify-center !gap-[4px]"
|
className="!flex !rounded-[8px] !items-center !p-2 !justify-center !gap-[4px]"
|
||||||
>
|
>
|
||||||
<RefreshA />
|
<RefreshA />
|
||||||
<p className="text-[#FAFAFA] text-[16px] font-medium">
|
<p className="text-[var(--ap-surface-2)] text-[16px] font-medium">
|
||||||
تلاش دوباره
|
تلاش دوباره
|
||||||
</p>
|
</p>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -65,12 +65,6 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
|
|||||||
// Service-based booking (روش نوبتدهی سرویسی)
|
// Service-based booking (روش نوبتدهی سرویسی)
|
||||||
const [selectedServiceUuids, setSelectedServiceUuids] = useState([]);
|
const [selectedServiceUuids, setSelectedServiceUuids] = useState([]);
|
||||||
|
|
||||||
// نوبتدهی منبعمحور: دستگاه/اتاق/یونیتِ همین پزشک در همین محل.
|
|
||||||
// `selectedResource === null` یعنی «نوبت با پزشک» — همان جریان همیشگی.
|
|
||||||
const [resources, setResources] = useState([]);
|
|
||||||
const [selectedResource, setSelectedResource] = useState(null);
|
|
||||||
const [resourceConfirmed, setResourceConfirmed] = useState(false);
|
|
||||||
|
|
||||||
// محل نوبتدهی: مطب شخصی یا یکی از کلینیکهایی که پزشک در آن برنامه دارد.
|
// محل نوبتدهی: مطب شخصی یا یکی از کلینیکهایی که پزشک در آن برنامه دارد.
|
||||||
const [bookingLocations, setBookingLocations] = useState([]);
|
const [bookingLocations, setBookingLocations] = useState([]);
|
||||||
const [selectedLocation, setSelectedLocation] = useState(null);
|
const [selectedLocation, setSelectedLocation] = useState(null);
|
||||||
@@ -126,81 +120,21 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [doctor?.uuid, browsingDate]);
|
}, [doctor?.uuid, browsingDate]);
|
||||||
|
|
||||||
// منابع **مستقل از محلها** گرفته میشوند و یکبار برای همهٔ محیطهای پزشک.
|
|
||||||
//
|
|
||||||
// وابستهکردنش به محل، پزشکی را که خودش برنامهٔ نوبتدهی ندارد ولی دستگاهش دارد از
|
|
||||||
// دسترس خارج میکرد: «محل» از برنامهٔ هفتگیِ پزشک میآید و برای او خالی است، در حالی
|
|
||||||
// که دستگاه تقویم و شعبهٔ خودش را دارد. هر منبع `clinic_uuid` خودش را همراه دارد.
|
|
||||||
//
|
|
||||||
// خطا عمداً بیصدا است — پزشکِ بدون منبع و خطای شبکه هر دو یعنی «مرحلهٔ منبع را نشان
|
|
||||||
// نده»، و جریان پزشکمحور باید دستنخورده ادامه یابد.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!doctor?.uuid) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
request
|
|
||||||
.getBookingResources(doctor.uuid)
|
|
||||||
.then((res) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const d = res?.data?.data ?? res?.data ?? {};
|
|
||||||
setResources(Array.isArray(d.resources) ? d.resources : []);
|
|
||||||
})
|
|
||||||
.catch(() => !cancelled && setResources([]));
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [doctor?.uuid]);
|
|
||||||
|
|
||||||
// منابعِ محلِ انتخابشده. وقتی محلی وجود ندارد (پزشکِ بدون برنامهٔ هفتگی) همهٔ منابع
|
|
||||||
// نشان داده میشوند، چون خودشان محیطشان را همراه دارند.
|
|
||||||
const locationResources =
|
|
||||||
bookingLocations.length === 0
|
|
||||||
? resources
|
|
||||||
: resources.filter(
|
|
||||||
(r) => (r.clinic_uuid ?? null) === (selectedLocation?.clinic_uuid ?? null)
|
|
||||||
);
|
|
||||||
|
|
||||||
// روش نوبتدهی و سرویسها per-location هستند: یک پزشک میتواند در مطب شخصی
|
// روش نوبتدهی و سرویسها per-location هستند: یک پزشک میتواند در مطب شخصی
|
||||||
// اسلاتی و در کلینیک سرویسی باشد.
|
// اسلاتی و در کلینیک سرویسی باشد.
|
||||||
//
|
const bookingMode = selectedLocation?.booking_mode === "service" ? "service" : "slot";
|
||||||
// منبع این را بازنویسی میکند: منبع اسلاتِ ثابت ندارد و زمانهایش همیشه از مدتِ
|
const bookingServices = selectedLocation?.services ?? [];
|
||||||
// سرویسهای انتخابشده ساخته میشوند، حتی اگر برنامهٔ همان محل اسلاتی باشد.
|
|
||||||
const bookingMode =
|
|
||||||
selectedResource !== null || selectedLocation?.booking_mode === "service"
|
|
||||||
? "service"
|
|
||||||
: "slot";
|
|
||||||
const bookingServices = selectedResource?.services ?? selectedLocation?.services ?? [];
|
|
||||||
|
|
||||||
// تعویض محل، انتخابهای وابسته را باطل میکند؛ وگرنه ترکیب سرویسِ یک محل با
|
// تعویض محل، انتخابهای وابسته را باطل میکند؛ وگرنه ترکیب سرویسِ یک محل با
|
||||||
// اسلات محل دیگر ساخته میشود و ثبت نوبت با ۴۲۲ رد میشود.
|
// اسلات محل دیگر ساخته میشود و ثبت نوبت با ۴۲۲ رد میشود.
|
||||||
//
|
|
||||||
// باطلسازی آبشاری است: محل ← منبع ← سرویس ← روز ← ساعت. هر سطح، همهٔ سطوح
|
|
||||||
// پایینترش را پاک میکند.
|
|
||||||
const changeLocation = (location) => {
|
const changeLocation = (location) => {
|
||||||
setSelectedLocation(location);
|
setSelectedLocation(location);
|
||||||
setLocationConfirmed(true);
|
setLocationConfirmed(true);
|
||||||
setSelectedResource(null);
|
|
||||||
setResourceConfirmed(false);
|
|
||||||
setSelectedServiceUuids([]);
|
setSelectedServiceUuids([]);
|
||||||
setSelectedSlot(null);
|
setSelectedSlot(null);
|
||||||
setSelectedDate(null);
|
setSelectedDate(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// انتخاب «نوبت با پزشک» (`resource === null`) هم یک انتخاب است و مرحله را میبندد.
|
|
||||||
const changeResource = (resource) => {
|
|
||||||
setSelectedResource(resource);
|
|
||||||
setResourceConfirmed(true);
|
|
||||||
setSelectedServiceUuids([]);
|
|
||||||
setSelectedSlot(null);
|
|
||||||
setSelectedDate(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const reopenResourceChoice = () => setResourceConfirmed(false);
|
|
||||||
|
|
||||||
// منبعی وجود ندارد ⇒ مرحله اصلاً رندر نمیشود و تجربهٔ فعلی بدون تغییر میماند.
|
|
||||||
const resourceStepNeeded = locationResources.length > 0 && !resourceConfirmed;
|
|
||||||
|
|
||||||
// بازگشت به مرحلهٔ انتخاب محل
|
// بازگشت به مرحلهٔ انتخاب محل
|
||||||
const reopenLocationChoice = () => setLocationConfirmed(false);
|
const reopenLocationChoice = () => setLocationConfirmed(false);
|
||||||
|
|
||||||
@@ -281,8 +215,8 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<div className="w-12 h-12 border-4 border-[#5559CE] border-t-transparent rounded-full animate-spin"></div>
|
<div className="w-12 h-12 border-4 border-[var(--ap-primary)] border-t-transparent rounded-full animate-spin"></div>
|
||||||
<p className="text-[#3B3B3B] text-[16px]">در حال بارگذاری...</p>
|
<p className="text-[var(--ap-text)] text-[16px]">در حال بارگذاری...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -323,11 +257,6 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
|
|||||||
locationConfirmed={locationConfirmed}
|
locationConfirmed={locationConfirmed}
|
||||||
changeLocation={changeLocation}
|
changeLocation={changeLocation}
|
||||||
reopenLocationChoice={reopenLocationChoice}
|
reopenLocationChoice={reopenLocationChoice}
|
||||||
resources={locationResources}
|
|
||||||
selectedResource={selectedResource}
|
|
||||||
resourceStepNeeded={resourceStepNeeded}
|
|
||||||
changeResource={changeResource}
|
|
||||||
reopenResourceChoice={reopenResourceChoice}
|
|
||||||
onDateChange={setBrowsingDate}
|
onDateChange={setBrowsingDate}
|
||||||
selectedClosedOnDate={selectedClosedOnDate}
|
selectedClosedOnDate={selectedClosedOnDate}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
|
|||||||
<div className="w-[18px] h-[18px] md:w-[20px] md:h-[20px]">
|
<div className="w-[18px] h-[18px] md:w-[20px] md:h-[20px]">
|
||||||
<LocationA />
|
<LocationA />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#616161] text-[14px] md:text-[16px] font-normal">
|
<p className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-normal">
|
||||||
آدرس: {address}
|
آدرس: {address}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
@@ -39,10 +39,10 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
|
|||||||
<CalendarA />
|
<CalendarA />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-start">
|
<div className="flex items-center justify-start">
|
||||||
<p className="text-[#616161] text-[14px] md:text-[16px] font-medium">
|
<p className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
|
||||||
تاریخ نوبت:{" "}
|
تاریخ نوبت:{" "}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#2F2F2F] text-[14px] md:text-[16px] font-medium mr-1">
|
<p className="text-[var(--ap-heading)] text-[14px] md:text-[16px] font-medium mr-1">
|
||||||
{formattedDate}
|
{formattedDate}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,7 +53,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
|
|||||||
onClick={() => setStep(0)}
|
onClick={() => setStep(0)}
|
||||||
variant="text"
|
variant="text"
|
||||||
>
|
>
|
||||||
<p className="!text-[#F17732] !text-[12px] md:!text-[14px] !font-normal">
|
<p className="!text-[var(--ap-accent)] !text-[12px] md:!text-[14px] !font-normal">
|
||||||
تغییر{" "}
|
تغییر{" "}
|
||||||
</p>
|
</p>
|
||||||
<div className="w-[16px] h-[16px] sm:w-[18px] sm:h-[18px] md:w-[21px] md:h-[21px] lg:w-[24px] lg:h-[24px]">
|
<div className="w-[16px] h-[16px] sm:w-[18px] sm:h-[18px] md:w-[21px] md:h-[21px] lg:w-[24px] lg:h-[24px]">
|
||||||
@@ -68,10 +68,10 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
|
|||||||
<ClockA />
|
<ClockA />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-start">
|
<div className="flex items-center justify-start">
|
||||||
<p className="text-[#616161] text-[14px] md:text-[16px] font-medium">
|
<p className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
|
||||||
زمان نوبت:{" "}
|
زمان نوبت:{" "}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#2F2F2F] text-[14px] md:text-[16px] font-medium mr-1">
|
<p className="text-[var(--ap-heading)] text-[14px] md:text-[16px] font-medium mr-1">
|
||||||
ساعت {formattedTime}
|
ساعت {formattedTime}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,7 +82,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
|
|||||||
onClick={() => setStep(0)}
|
onClick={() => setStep(0)}
|
||||||
variant="text"
|
variant="text"
|
||||||
>
|
>
|
||||||
<p className="!text-[#F17732] !text-[12px] md:!text-[14px] !font-normal">
|
<p className="!text-[var(--ap-accent)] !text-[12px] md:!text-[14px] !font-normal">
|
||||||
تغییر{" "}
|
تغییر{" "}
|
||||||
</p>
|
</p>
|
||||||
<div className="w-[16px] h-[16px] sm:w-[18px] sm:h-[18px] md:w-[21px] md:h-[21px] lg:w-[24px] lg:h-[24px]">
|
<div className="w-[16px] h-[16px] sm:w-[18px] sm:h-[18px] md:w-[21px] md:h-[21px] lg:w-[24px] lg:h-[24px]">
|
||||||
@@ -93,7 +93,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<span
|
<span
|
||||||
className="mt-[10px] sm:mt-[16px] md:mt-[22px] lg:mt-[28px] mb-[8px] sm:mb-[13px] md:mb-[19px] lg:mb-[24px] w-full h-px bg-[#D7D7D7] block"
|
className="mt-[10px] sm:mt-[16px] md:mt-[22px] lg:mt-[28px] mb-[8px] sm:mb-[13px] md:mb-[19px] lg:mb-[24px] w-full h-px bg-[var(--ap-border-2)] block"
|
||||||
></span>
|
></span>
|
||||||
<ul className="flex flex-col items-start justify-start gap-[8px] sm:gap-[13px] md:gap-[19px] lg:gap-[24px]">
|
<ul className="flex flex-col items-start justify-start gap-[8px] sm:gap-[13px] md:gap-[19px] lg:gap-[24px]">
|
||||||
{/* <li className="flex items-center justify-start gap-[4px] md:gap-[6px] lg:gap-[8px]">
|
{/* <li className="flex items-center justify-start gap-[4px] md:gap-[6px] lg:gap-[8px]">
|
||||||
@@ -102,7 +102,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
|
|||||||
>
|
>
|
||||||
<TickCircleOrange />
|
<TickCircleOrange />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#616161] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
|
<p className="text-[var(--ap-text-2)] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
|
||||||
فقط در صورت لغو نوبت تا 5 ساعت قبل از زمان ویزیت، امکان استرداد وجه
|
فقط در صورت لغو نوبت تا 5 ساعت قبل از زمان ویزیت، امکان استرداد وجه
|
||||||
ممکن می باشد.
|
ممکن می باشد.
|
||||||
</p>
|
</p>
|
||||||
@@ -113,7 +113,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
|
|||||||
>
|
>
|
||||||
<TickCircleOrange />
|
<TickCircleOrange />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#616161] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
|
<p className="text-[var(--ap-text-2)] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
|
||||||
لطفا به موقع در مطب حضور داشته باشید.
|
لطفا به موقع در مطب حضور داشته باشید.
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ function Head({ doctor }) {
|
|||||||
className=" w-[40px] sm:w-[58px] md:w-[77px] lg:w-[95px] h-[40px] sm:h-[58px] md:h-[77px] lg:h-[95px] "
|
className=" w-[40px] sm:w-[58px] md:w-[77px] lg:w-[95px] h-[40px] sm:h-[58px] md:h-[77px] lg:h-[95px] "
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-start justify-center gap-[8px] sm:gap-[12px] md:gap-[16px] lg:gap-[20px]">
|
<div className="flex flex-col items-start justify-center gap-[8px] sm:gap-[12px] md:gap-[16px] lg:gap-[20px]">
|
||||||
<p className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
|
<p className="text-[var(--ap-text)] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
|
||||||
{doctor?.name}
|
{doctor?.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#616161] text-[14px] md:text-[16px] font-medium">
|
<p className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
|
||||||
تخصص: {expertiseText}
|
تخصص: {expertiseText}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ function Information({ doctor, step, setStep, typePay, isPay, disableSide, selec
|
|||||||
className={`w-full lg:w-[41%] relative ${disableSide ? "hidden lg:flex" : "" }`}
|
className={`w-full lg:w-[41%] relative ${disableSide ? "hidden lg:flex" : "" }`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px] sticky right-0 w-full opacity-page rounded-[8px] border border-solid border-[#EFEFEF] bg-[#FFF] top-[calc(12px_+_75px_+_32px)] sm:top-[calc(21px_+_75px_+_45px)] md:top-[calc(30px_+_75px_+_58px)] lg:top-[calc(40px_+_75px_+_78px)] ${typePay === "success" || typePay === "failed" ? "hidden md:block" : "block" } `}
|
className={`p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px] sticky right-0 w-full opacity-page rounded-[8px] border border-solid border-[var(--ap-border)] bg-[var(--ap-surface)] top-[calc(12px_+_75px_+_32px)] sm:top-[calc(21px_+_75px_+_45px)] md:top-[calc(30px_+_75px_+_58px)] lg:top-[calc(40px_+_75px_+_78px)] ${typePay === "success" || typePay === "failed" ? "hidden md:block" : "block" } `}
|
||||||
>
|
>
|
||||||
<p className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
|
<p className="text-[var(--ap-text)] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
|
||||||
جزئیات نوبت
|
جزئیات نوبت
|
||||||
</p>
|
</p>
|
||||||
<Head doctor={doctor} />
|
<Head doctor={doctor} />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Address({ doctor, selectedLocation }) {
|
|||||||
return (
|
return (
|
||||||
<ul className="hidden mt-[4px] lg:flex flex-col">
|
<ul className="hidden mt-[4px] lg:flex flex-col">
|
||||||
<li>
|
<li>
|
||||||
<div className="flex py-[12px] text-[#616161] text-[14px] items-start justify-start">
|
<div className="flex py-[12px] text-[var(--ap-text-2)] text-[14px] items-start justify-start">
|
||||||
<p className="font-bold min-w-[115px]">{`${selectedLocation.title}: `}</p>
|
<p className="font-bold min-w-[115px]">{`${selectedLocation.title}: `}</p>
|
||||||
<p className="font-normal">{selectedLocation.address || "—"}</p>
|
<p className="font-normal">{selectedLocation.address || "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -20,12 +20,12 @@ function Address({ doctor, selectedLocation }) {
|
|||||||
<ul className="hidden mt-[4px] lg:flex flex-col">
|
<ul className="hidden mt-[4px] lg:flex flex-col">
|
||||||
{doctor?.address?.map((item, idx) => (
|
{doctor?.address?.map((item, idx) => (
|
||||||
<li key={idx}>
|
<li key={idx}>
|
||||||
<div className="flex py-[12px] text-[#616161] text-[14px] items-start justify-start">
|
<div className="flex py-[12px] text-[var(--ap-text-2)] text-[14px] items-start justify-start">
|
||||||
<p className="font-bold min-w-[115px]">{`${item.name}: `}</p>
|
<p className="font-bold min-w-[115px]">{`${item.name}: `}</p>
|
||||||
<p className="font-normal">{item.address || item.location}</p>
|
<p className="font-normal">{item.address || item.location}</p>
|
||||||
</div>
|
</div>
|
||||||
{doctor.address.length > idx + 1 && (
|
{doctor.address.length > idx + 1 && (
|
||||||
<span className="bg-[#EFEFEF] block h-px w-full"></span>
|
<span className="bg-[var(--ap-border)] block h-px w-full"></span>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ function Head({ doctor }) {
|
|||||||
className=" w-[48px] sm:w-[51px] md:w-[54px] lg:w-[56px] h-[48px] sm:h-[51px] md:h-[54px] lg:h-[56px] "
|
className=" w-[48px] sm:w-[51px] md:w-[54px] lg:w-[56px] h-[48px] sm:h-[51px] md:h-[54px] lg:h-[56px] "
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-start justify-start gap-[8px]">
|
<div className="flex flex-col items-start justify-start gap-[8px]">
|
||||||
<h2 className="text-[#3B3B3B] text-[12px] md:text-[14px] font-bold">
|
<h2 className="text-[var(--ap-text)] text-[12px] md:text-[14px] font-bold">
|
||||||
{doctor?.name}
|
{doctor?.name}
|
||||||
</h2>
|
</h2>
|
||||||
<h3 className="text-[#525252] text-[12px] md:text-[14px] font-medium">
|
<h3 className="text-[var(--ap-muted)] text-[12px] md:text-[14px] font-medium">
|
||||||
تخصص: {expertiseText}
|
تخصص: {expertiseText}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ function LocationSelect({ locations = [], selected, onSelect }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-[520px] mx-auto">
|
<div className="w-full max-w-[520px] mx-auto">
|
||||||
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب محل نوبتدهی</h2>
|
<h2 className="text-[16px] font-bold text-[var(--ap-text)] mb-4">۱. انتخاب محل نوبتدهی</h2>
|
||||||
|
|
||||||
{!anyOpen && locations.length > 0 && (
|
{!anyOpen && locations.length > 0 && (
|
||||||
<p className="mb-3 text-[13px] text-[#B4541F]">
|
<p className="mb-3 text-[13px] text-[var(--ap-accent-strong)]">
|
||||||
در روز انتخابشده هیچکدام از محلها نوبتدهی ندارند. روز دیگری را انتخاب کنید.
|
در روز انتخابشده هیچکدام از محلها نوبتدهی ندارند. روز دیگری را انتخاب کنید.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -43,32 +43,32 @@ function LocationSelect({ locations = [], selected, onSelect }) {
|
|||||||
onClick={() => !closed && onSelect(location)}
|
onClick={() => !closed && onSelect(location)}
|
||||||
className={`w-full text-right p-[16px] rounded-[8px] border border-solid transition-colors ${
|
className={`w-full text-right p-[16px] rounded-[8px] border border-solid transition-colors ${
|
||||||
closed
|
closed
|
||||||
? "border-[#EFEFEF] bg-[#FAFAFA] opacity-60 cursor-not-allowed"
|
? "border-[var(--ap-border)] bg-[var(--ap-surface-2)] opacity-60 cursor-not-allowed"
|
||||||
: active
|
: active
|
||||||
? "border-[#5559CE] bg-[rgba(85,89,206,0.06)]"
|
? "border-[var(--ap-primary)] bg-[rgba(85,89,206,0.06)]"
|
||||||
: "border-[#EFEFEF] bg-[#FFF] hover:border-[#C7C9EC]"
|
: "border-[var(--ap-border)] bg-[var(--ap-surface)] hover:border-[#C7C9EC]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-[8px]">
|
<div className="flex items-center justify-between gap-[8px]">
|
||||||
<p className="text-[14px] md:text-[16px] font-bold text-[#3B3B3B]">
|
<p className="text-[14px] md:text-[16px] font-bold text-[var(--ap-text)]">
|
||||||
{location.title}
|
{location.title}
|
||||||
</p>
|
</p>
|
||||||
{location.type === "clinic" && (
|
{location.type === "clinic" && (
|
||||||
<span className="text-[11px] text-[#5559CE] bg-[rgba(85,89,206,0.10)] rounded-[4px] px-[6px] py-[2px]">
|
<span className="text-[11px] text-[var(--ap-primary)] bg-[rgba(85,89,206,0.10)] rounded-[4px] px-[6px] py-[2px]">
|
||||||
کلینیک
|
کلینیک
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{location.address && (
|
{location.address && (
|
||||||
<p className="mt-[6px] text-[13px] text-[#616161] font-normal">
|
<p className="mt-[6px] text-[13px] text-[var(--ap-text-2)] font-normal">
|
||||||
{location.address}
|
{location.address}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p
|
<p
|
||||||
className={`mt-[8px] text-[12px] ${
|
className={`mt-[8px] text-[12px] ${
|
||||||
closed ? "text-[#B4541F]" : nextLabel ? "text-[#009D79]" : "text-[#A1A1A1]"
|
closed ? "text-[var(--ap-accent-strong)]" : nextLabel ? "text-[#009D79]" : "text-[#A1A1A1]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{closed
|
{closed
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ function Location({ doctor, selectedLocation }) {
|
|||||||
return (
|
return (
|
||||||
<div className="h-fit lg:h-screen w-full lg:w-[39%]">
|
<div className="h-fit lg:h-screen w-full lg:w-[39%]">
|
||||||
<div
|
<div
|
||||||
className="p-0 lg:p-[16px] sticky rounded-[8px] bg-transparent lg:bg-[#FFF] border border-solid border-transparent lg:border-[#EFEFEF] top-[calc(12px_+_75px_+_32px)] sm:top-[calc(21px_+_75px_+_45px)] md:top-[calc(30px_+_75px_+_58px)] lg:top-[calc(40px_+_75px_+_78px)]"
|
className="p-0 lg:p-[16px] sticky rounded-[8px] bg-transparent lg:bg-[var(--ap-surface)] border border-solid border-transparent lg:border-[var(--ap-border)] top-[calc(12px_+_75px_+_32px)] sm:top-[calc(21px_+_75px_+_45px)] md:top-[calc(30px_+_75px_+_58px)] lg:top-[calc(40px_+_75px_+_78px)]"
|
||||||
>
|
>
|
||||||
<Head doctor={doctor} />
|
<Head doctor={doctor} />
|
||||||
<span className="block bg-[#EFEFEF] lg:bg-transparent w-full h-px lg:my-[6px] mt-[16px]"></span>
|
<span className="block bg-[var(--ap-border)] lg:bg-transparent w-full h-px lg:my-[6px] mt-[16px]"></span>
|
||||||
<Address doctor={doctor} selectedLocation={selectedLocation} />
|
<Address doctor={doctor} selectedLocation={selectedLocation} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from "react";
|
|||||||
function ButtonFixed({ children }) {
|
function ButtonFixed({ children }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="!fixed md:!relative !bottom-0 !right-0 bg-[#FFF] border-0 border-t md:border-t-transparent border-[#D7D7D7] z-10 !w-full sm:!w-full !p-[16px] sm:!px-[52px] md:!ml-0 md:!p-0 md:!w-full"
|
className="!fixed md:!relative !bottom-0 !right-0 bg-[var(--ap-surface)] border-0 border-t md:border-t-transparent border-[var(--ap-border-2)] z-10 !w-full sm:!w-full !p-[16px] sm:!px-[52px] md:!ml-0 md:!p-0 md:!w-full"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,29 +69,29 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full lg:w-[59%]">
|
<div className="w-full lg:w-[59%]">
|
||||||
<div className="opacity-page bg-[#FFF] border border-solid border-[#EFEFEF] rounded-[12px] p-[16px] md:p-[24px]">
|
<div className="opacity-page bg-[var(--ap-surface)] border border-solid border-[var(--ap-border)] rounded-[12px] p-[16px] md:p-[24px]">
|
||||||
<p className="text-[#3B3B3B] text-[16px] md:text-[20px] font-bold mb-[20px]">
|
<p className="text-[var(--ap-text)] text-[16px] md:text-[20px] font-bold mb-[20px]">
|
||||||
تأیید و پرداخت
|
تأیید و پرداخت
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* ── خلاصه نوبت ── */}
|
{/* ── خلاصه نوبت ── */}
|
||||||
<div className="rounded-[10px] border border-[#EFEFEF] bg-[#FAFAFA] p-[16px] flex flex-col gap-[12px]">
|
<div className="rounded-[10px] border border-[var(--ap-border)] bg-[var(--ap-surface-2)] p-[16px] flex flex-col gap-[12px]">
|
||||||
{doctor?.name && (
|
{doctor?.name && (
|
||||||
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
|
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
|
||||||
<span className="text-[#9B9B9B]">پزشک</span>
|
<span className="text-[var(--ap-text-4)]">پزشک</span>
|
||||||
<span className="text-[#3B3B3B] font-medium">{doctor.name}</span>
|
<span className="text-[var(--ap-text)] font-medium">{doctor.name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{patientName && (
|
{patientName && (
|
||||||
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
|
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
|
||||||
<span className="text-[#9B9B9B]">بیمار</span>
|
<span className="text-[var(--ap-text-4)]">بیمار</span>
|
||||||
<span className="text-[#3B3B3B] font-medium">{patientName}</span>
|
<span className="text-[var(--ap-text)] font-medium">{patientName}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedSlot?.start && (
|
{selectedSlot?.start && (
|
||||||
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
|
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
|
||||||
<span className="text-[#9B9B9B]">زمان نوبت</span>
|
<span className="text-[var(--ap-text-4)]">زمان نوبت</span>
|
||||||
<span className="text-[#3B3B3B] font-medium" dir="rtl">
|
<span className="text-[var(--ap-text)] font-medium" dir="rtl">
|
||||||
{/* utcOffset(210) = ساعت رسمی ایران (+03:30)، مستقل از tz مرورگر بیمار */}
|
{/* utcOffset(210) = ساعت رسمی ایران (+03:30)، مستقل از tz مرورگر بیمار */}
|
||||||
{moment.unix(selectedSlot.start).utcOffset(210).locale("fa").format("dddd jD jMMMM jYYYY")}
|
{moment.unix(selectedSlot.start).utcOffset(210).locale("fa").format("dddd jD jMMMM jYYYY")}
|
||||||
{" — ساعت "}
|
{" — ساعت "}
|
||||||
@@ -102,12 +102,12 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── مبلغ ── */}
|
{/* ── مبلغ ── */}
|
||||||
<div className="flex justify-between items-center mt-[20px] py-[14px] border-y border-[#EFEFEF]">
|
<div className="flex justify-between items-center mt-[20px] py-[14px] border-y border-[var(--ap-border)]">
|
||||||
<span className="text-[#616161] text-[14px] md:text-[16px] font-medium">
|
<span className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
|
||||||
مبلغ قابل پرداخت
|
مبلغ قابل پرداخت
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[#3B3B3B] text-[18px] md:text-[20px] font-bold">
|
<span className="text-[var(--ap-text)] text-[18px] md:text-[20px] font-bold">
|
||||||
{toman} <span className="text-[14px] font-normal text-[#616161]">تومان</span>
|
{toman} <span className="text-[14px] font-normal text-[var(--ap-text-2)]">تومان</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -115,18 +115,18 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
|
|||||||
{!expired && (
|
{!expired && (
|
||||||
<div className="mt-[20px]">
|
<div className="mt-[20px]">
|
||||||
<div className="flex justify-between items-center mb-[8px]">
|
<div className="flex justify-between items-center mb-[8px]">
|
||||||
<span className="text-[#616161] text-[13px] md:text-[14px]">
|
<span className="text-[var(--ap-text-2)] text-[13px] md:text-[14px]">
|
||||||
مهلت پرداخت و نگهداری نوبت
|
مهلت پرداخت و نگهداری نوبت
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`text-[14px] md:text-[16px] font-bold tabular-nums ${ timeLeft < 60 ? "text-[#E0383B]" : "text-[#5559CE]" }`}
|
className={`text-[14px] md:text-[16px] font-bold tabular-nums ${ timeLeft < 60 ? "text-[var(--ap-danger)]" : "text-[var(--ap-primary)]" }`}
|
||||||
>
|
>
|
||||||
{formatTime(timeLeft)}
|
{formatTime(timeLeft)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-[6px] rounded-full bg-[#EFEFEF] overflow-hidden">
|
<div className="w-full h-[6px] rounded-full bg-[var(--ap-border)] overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-1000 ease-linear ${ timeLeft < 60 ? "bg-[#E0383B]" : "bg-[#5559CE]" }`}
|
className={`h-full rounded-full transition-all duration-1000 ease-linear ${ timeLeft < 60 ? "bg-[var(--ap-danger)]" : "bg-[var(--ap-primary)]" }`}
|
||||||
style={{ width: `${progress}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,18 +137,18 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
|
|||||||
{!expired && (
|
{!expired && (
|
||||||
<div className="mt-[20px]">
|
<div className="mt-[20px]">
|
||||||
{testMode ? (
|
{testMode ? (
|
||||||
<div className="flex items-start gap-[8px] rounded-[10px] border border-[#F4B740] bg-[#FFF8E8] p-[12px]">
|
<div className="flex items-start gap-[8px] rounded-[10px] border border-[var(--ap-warning)] bg-[var(--ap-surface)8E8] p-[12px]">
|
||||||
<span className="text-[18px] leading-none">🧪</span>
|
<span className="text-[18px] leading-none">🧪</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[#8A6100] text-[14px] font-bold">درگاه پرداخت آزمایشی</p>
|
<p className="text-[var(--ap-warning-text)] text-[14px] font-bold">درگاه پرداخت آزمایشی</p>
|
||||||
<p className="text-[#8A6100] text-[12px] mt-[2px] leading-[20px]">
|
<p className="text-[var(--ap-warning-text)] text-[12px] mt-[2px] leading-[20px]">
|
||||||
در این حالت پرداخت واقعی انجام نمیشود و نوبت بهصورت آزمایشی تأیید میگردد.
|
در این حالت پرداخت واقعی انجام نمیشود و نوبت بهصورت آزمایشی تأیید میگردد.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : gateways.length === 0 ? (
|
) : gateways.length === 0 ? (
|
||||||
<div className="rounded-[10px] border border-[#F3C0C1] bg-[#FDEEEE] p-[14px]">
|
<div className="rounded-[10px] border border-[var(--ap-danger-border)] bg-[var(--ap-danger-bg)] p-[14px]">
|
||||||
<p className="text-[#E0383B] text-[14px] font-medium">
|
<p className="text-[var(--ap-danger)] text-[14px] font-medium">
|
||||||
درگاه پرداخت فعالی موجود نیست. لطفاً بعداً تلاش کنید.
|
درگاه پرداخت فعالی موجود نیست. لطفاً بعداً تلاش کنید.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,8 +173,8 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{expired && (
|
{expired && (
|
||||||
<div className="mt-[20px] rounded-[10px] border border-[#F3C0C1] bg-[#FDEEEE] p-[14px]">
|
<div className="mt-[20px] rounded-[10px] border border-[var(--ap-danger-border)] bg-[var(--ap-danger-bg)] p-[14px]">
|
||||||
<p className="text-[#E0383B] text-[14px] font-medium">
|
<p className="text-[var(--ap-danger)] text-[14px] font-medium">
|
||||||
مهلت پرداخت تمام شد و نوبت آزاد شد. لطفاً دوباره زمان نوبت را انتخاب کنید.
|
مهلت پرداخت تمام شد و نوبت آزاد شد. لطفاً دوباره زمان نوبت را انتخاب کنید.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,7 +188,7 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
|
|||||||
onClick={() => setStep(0)}
|
onClick={() => setStep(0)}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
>
|
>
|
||||||
<span className="text-[#FFF] text-[16px] font-medium">انتخاب مجدد زمان</span>
|
<span className="text-[var(--ap-surface)] text-[16px] font-medium">انتخاب مجدد زمان</span>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -200,7 +200,7 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
|
|||||||
(!testMode && !selectedBank)
|
(!testMode && !selectedBank)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className={`text-[#FFF] text-[16px] font-medium ${loading ? "opacity-0" : ""}`}>
|
<span className={`text-[var(--ap-surface)] text-[16px] font-medium ${loading ? "opacity-0" : ""}`}>
|
||||||
{testMode ? "پرداخت آزمایشی" : `پرداخت ${toman} تومان`}
|
{testMode ? "پرداخت آزمایشی" : `پرداخت ${toman} تومان`}
|
||||||
</span>
|
</span>
|
||||||
{!loading && <ArrowLeftB />}
|
{!loading && <ArrowLeftB />}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import ResourceSelect from './index';
|
|
||||||
|
|
||||||
const resource = (over = {}) => ({
|
|
||||||
uuid: 'r1',
|
|
||||||
name: 'کندلا2021',
|
|
||||||
type: { code: 'laser_device', name: 'دستگاه لیزر' },
|
|
||||||
services: [{ uuid: 's1', name: 'لیزر دست', duration_minutes: 20, price_rials: 2000000 }],
|
|
||||||
...over,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ResourceSelect', () => {
|
|
||||||
it('کارت «نوبت با پزشک» همیشه اول میآید', () => {
|
|
||||||
render(<ResourceSelect resources={[resource()]} onSelect={vi.fn()} />);
|
|
||||||
|
|
||||||
const buttons = screen.getAllByRole('button');
|
|
||||||
expect(buttons).toHaveLength(2);
|
|
||||||
expect(buttons[0]).toHaveTextContent('نوبت با پزشک');
|
|
||||||
expect(buttons[1]).toHaveTextContent('کندلا2021');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('نوع منبع بهعنوان برچسب دیده میشود', () => {
|
|
||||||
render(<ResourceSelect resources={[resource()]} onSelect={vi.fn()} />);
|
|
||||||
expect(screen.getByText('دستگاه لیزر')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('انتخاب «نوبت با پزشک» مقدار null میفرستد', async () => {
|
|
||||||
const onSelect = vi.fn();
|
|
||||||
render(<ResourceSelect resources={[resource()]} onSelect={onSelect} />);
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByText('نوبت با پزشک'));
|
|
||||||
expect(onSelect).toHaveBeenCalledWith(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('انتخاب منبع، خودِ آبجکت منبع را میفرستد', async () => {
|
|
||||||
const onSelect = vi.fn();
|
|
||||||
const r = resource();
|
|
||||||
render(<ResourceSelect resources={[r]} onSelect={onSelect} />);
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByText('کندلا2021'));
|
|
||||||
expect(onSelect).toHaveBeenCalledWith(r);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('تا سه سرویس با نام میآید و بقیه با +n', () => {
|
|
||||||
const services = ['الف', 'ب', 'ج', 'د', 'ه'].map((n, i) => ({ uuid: `s${i}`, name: n }));
|
|
||||||
render(<ResourceSelect resources={[resource({ services })]} onSelect={vi.fn()} />);
|
|
||||||
|
|
||||||
expect(screen.getByText('الف، ب، ج +2')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('منبعِ بدون سرویس، زیرنویس خالی نمیسازد', () => {
|
|
||||||
render(<ResourceSelect resources={[resource({ services: [] })]} onSelect={vi.fn()} />);
|
|
||||||
|
|
||||||
// فقط زیرنویسِ کارتِ پزشک میماند.
|
|
||||||
expect(screen.getAllByRole('button')[1].textContent).toBe('کندلا2021دستگاه لیزر');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('منبعِ بدون type برچسب نمیگیرد و نمیشکند', () => {
|
|
||||||
render(<ResourceSelect resources={[resource({ type: null })]} onSelect={vi.fn()} />);
|
|
||||||
expect(screen.getByText('کندلا2021')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
const MAX_PREVIEW_SERVICES = 3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* زیرنویس کارت: نام سرویسها تا سهتا و بعد «+n».
|
|
||||||
*
|
|
||||||
* شمارِ خالی («۳ سرویس») به بیمار نمیگوید این دستگاه به کارش میآید یا نه، و او را
|
|
||||||
* مجبور میکند کورکورانه کلیک کند.
|
|
||||||
*/
|
|
||||||
function servicesLabel(services = []) {
|
|
||||||
if (services.length === 0) return null;
|
|
||||||
|
|
||||||
const shown = services.slice(0, MAX_PREVIEW_SERVICES).map((s) => s.name);
|
|
||||||
const rest = services.length - shown.length;
|
|
||||||
|
|
||||||
return rest > 0 ? `${shown.join("، ")} +${rest}` : shown.join("، ");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* انتخاب نوع نوبت — پیش از انتخاب سرویس و روز، و فقط وقتی این پزشک در این محل
|
|
||||||
* دستکم یک منبعِ قابل رزرو دارد.
|
|
||||||
*
|
|
||||||
* «منبع» واژهٔ داخلی است و به بیمار نشان داده نمیشود؛ کارتها نام واقعی دستگاه/اتاق
|
|
||||||
* و نوعشان را نشان میدهند. کارت اول همیشه جریان همیشگی است: نوبت با خودِ پزشک.
|
|
||||||
*/
|
|
||||||
function ResourceSelect({ resources = [], selected = null, onSelect, allowDoctorOption = true }) {
|
|
||||||
const cardClass = (active) =>
|
|
||||||
`w-full text-right p-[16px] rounded-[8px] border border-solid transition-colors ${
|
|
||||||
active
|
|
||||||
? "border-[#5559CE] bg-[rgba(85,89,206,0.06)]"
|
|
||||||
: "border-[#EFEFEF] bg-[#FFF] hover:border-[#C7C9EC]"
|
|
||||||
}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-[520px] mx-auto">
|
|
||||||
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب نوع نوبت</h2>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{/* پزشکی که برنامهٔ هفتگی ندارد، ویزیتِ مستقیم هم ندارد؛ نشاندادن این کارت
|
|
||||||
کاربر را به فهرست ساعتِ همیشهخالی میبرد. */}
|
|
||||||
{allowDoctorOption && (
|
|
||||||
<button type="button" onClick={() => onSelect(null)} className={cardClass(selected === null)}>
|
|
||||||
<p className="text-[14px] md:text-[16px] font-bold text-[#3B3B3B]">نوبت با پزشک</p>
|
|
||||||
<p className="mt-[6px] text-[13px] text-[#616161] font-normal">
|
|
||||||
ویزیت معمول در ساعتهای کاری پزشک
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{resources.map((resource) => {
|
|
||||||
const active = selected?.uuid === resource.uuid;
|
|
||||||
const label = servicesLabel(resource.services);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={resource.uuid}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelect(resource)}
|
|
||||||
className={cardClass(active)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-[8px]">
|
|
||||||
<p className="text-[14px] md:text-[16px] font-bold text-[#3B3B3B]">
|
|
||||||
{resource.name}
|
|
||||||
</p>
|
|
||||||
{resource.type?.name && (
|
|
||||||
<span className="text-[11px] text-[#5559CE] bg-[rgba(85,89,206,0.10)] rounded-[4px] px-[6px] py-[2px] shrink-0">
|
|
||||||
{resource.type.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{label && (
|
|
||||||
<p className="mt-[6px] text-[13px] text-[#616161] font-normal">{label}</p>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ResourceSelect;
|
|
||||||
@@ -1,31 +1,77 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import moment from "jalali-moment";
|
||||||
|
import { request } from "@/services/response";
|
||||||
|
|
||||||
function toToman(rials) {
|
function toToman(rials) {
|
||||||
if (!rials) return null;
|
if (!rials) return null;
|
||||||
return Math.round(rials / 10).toLocaleString("fa-IR");
|
return Math.round(rials / 10).toLocaleString("fa-IR");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جمعِ سمتِ کلاینت — فقط fallback برای بکاندی که `total_duration_minutes` نمیدهد.
|
||||||
|
* مدت، دادهٔ سرور است: فرمولش قرار است به «زمان تنها / زمان اضافه» تغییر کند و هر
|
||||||
|
* محاسبهٔ موازی در فرانت از آن روز عددِ غلط نشان میدهد.
|
||||||
|
*/
|
||||||
|
function fallbackSum(services, uuids) {
|
||||||
|
return services
|
||||||
|
.filter((s) => uuids.includes(s.uuid))
|
||||||
|
.reduce((sum, s) => sum + (Number(s.duration_minutes) || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
// مرحلهٔ انتخاب سرویس (فقط حالت نوبتدهی سرویسی) — پیش از انتخاب روز.
|
// مرحلهٔ انتخاب سرویس (فقط حالت نوبتدهی سرویسی) — پیش از انتخاب روز.
|
||||||
function ServiceSelect({ services = [], onContinue }) {
|
function ServiceSelect({ services = [], onContinue, doctorUuid, clinicUuid = null }) {
|
||||||
const [draft, setDraft] = useState([]);
|
const [draft, setDraft] = useState([]);
|
||||||
|
/** `{ key, minutes }` — کلید همان انتخابی است که عدد به آن تعلق دارد. */
|
||||||
|
const [serverDuration, setServerDuration] = useState(null);
|
||||||
|
|
||||||
const toggle = (uuid) =>
|
const toggle = (uuid) =>
|
||||||
setDraft((prev) =>
|
setDraft((prev) =>
|
||||||
prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]
|
prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalMinutes = services
|
const draftKey = draft.join(",");
|
||||||
.filter((s) => draft.includes(s.uuid))
|
|
||||||
.reduce((sum, s) => sum + (Number(s.duration_minutes) || 0), 0);
|
// مدت را سرور حساب میکند. این مرحله پیش از انتخاب روز است، ولی
|
||||||
|
// `total_duration_minutes` به تاریخ وابسته نیست — بکاند آن را پیش از لمسِ
|
||||||
|
// شیفتهای آن روز برمیگرداند، پس حتی اگر امروز تعطیل باشد و `start_times`
|
||||||
|
// خالی بیاید، عدد درست است.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doctorUuid || draftKey === "") return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const today = moment().format("YYYY-MM-DD");
|
||||||
|
|
||||||
|
request
|
||||||
|
.getServiceSlots(doctorUuid, today, draftKey.split(","), clinicUuid)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const minutes = (res?.data?.data ?? res?.data)?.total_duration_minutes;
|
||||||
|
if (typeof minutes !== "number") {
|
||||||
|
console.warn("[booking] total_duration_minutes missing — falling back to client sum");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setServerDuration({ key: draftKey, minutes });
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [doctorUuid, clinicUuid, draftKey]);
|
||||||
|
|
||||||
|
// کهنهبودن **مشتق** میشود، نه با reset: عددِ متعلق به انتخاب قبلی خودکار نادیده
|
||||||
|
// گرفته میشود و effect لازم نیست در بدنهاش setState صدا بزند.
|
||||||
|
const serverMinutes = serverDuration?.key === draftKey ? serverDuration.minutes : null;
|
||||||
|
const totalMinutes = serverMinutes ?? fallbackSum(services, draft);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-[520px] mx-auto">
|
<div className="w-full max-w-[520px] mx-auto">
|
||||||
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب سرویس</h2>
|
<h2 className="text-[16px] font-bold text-[var(--ap-text)] mb-4">۱. انتخاب سرویس</h2>
|
||||||
|
|
||||||
{services.length === 0 ? (
|
{services.length === 0 ? (
|
||||||
<p className="text-[14px] text-[#7A7A7A]">
|
<p className="text-[14px] text-[var(--ap-text-3)]">
|
||||||
در حال حاضر سرویسی برای نوبتدهی آنلاین تعریف نشده است.
|
در حال حاضر سرویسی برای نوبتدهی آنلاین تعریف نشده است.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -40,27 +86,27 @@ function ServiceSelect({ services = [], onContinue }) {
|
|||||||
onClick={() => toggle(s.uuid)}
|
onClick={() => toggle(s.uuid)}
|
||||||
className={`flex items-center justify-between gap-3 p-3 rounded-xl border text-right transition-colors ${
|
className={`flex items-center justify-between gap-3 p-3 rounded-xl border text-right transition-colors ${
|
||||||
active
|
active
|
||||||
? "border-[#5559CE] bg-[#5559CE]/5"
|
? "border-[var(--ap-primary)] bg-[var(--ap-primary)]/5"
|
||||||
: "border-gray-200 bg-white hover:border-[#5559CE]"
|
: "border-gray-200 bg-white hover:border-[var(--ap-primary)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 min-w-0">
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
<span
|
<span
|
||||||
className={`w-4 h-4 rounded border shrink-0 grid place-items-center ${
|
className={`w-4 h-4 rounded border shrink-0 grid place-items-center ${
|
||||||
active ? "border-[#5559CE] bg-[#5559CE]" : "border-gray-300"
|
active ? "border-[var(--ap-primary)] bg-[var(--ap-primary)]" : "border-gray-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{active && (
|
{active && (
|
||||||
<span className="w-2 h-2 bg-white rounded-[2px]" />
|
<span className="w-2 h-2 bg-white rounded-[2px]" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[14px] text-[#3B3B3B] truncate">
|
<span className="text-[14px] text-[var(--ap-text)] truncate">
|
||||||
{s.name}
|
{s.name}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2 shrink-0 text-[12px] text-[#7A7A7A]">
|
<span className="flex items-center gap-2 shrink-0 text-[12px] text-[var(--ap-text-3)]">
|
||||||
{s.duration_minutes ? <span>{s.duration_minutes} دقیقه</span> : null}
|
{s.duration_minutes ? <span>{s.duration_minutes} دقیقه</span> : null}
|
||||||
{toman ? <span className="text-[#5559CE]">{toman} تومان</span> : null}
|
{toman ? <span className="text-[var(--ap-primary)]">{toman} تومان</span> : null}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -69,8 +115,11 @@ function ServiceSelect({ services = [], onContinue }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{draft.length > 0 && (
|
{draft.length > 0 && (
|
||||||
<div className="mt-4 text-[13px] text-[#7A7A7A]">
|
<div className="mt-4 text-[13px] text-[var(--ap-text-3)]">
|
||||||
مدت کل: <b className="text-[#3B3B3B]">{totalMinutes} دقیقه</b>
|
{/* تا وقتی عدد سرور نرسیده، «تقریبی» است — عددِ جمعِ کلاینت ممکن است با
|
||||||
|
مدت واقعی نوبت یکی نباشد. */}
|
||||||
|
{serverMinutes === null ? "مدت تقریبی" : "مدت کل"}:{" "}
|
||||||
|
<b className="text-[var(--ap-text)]">{totalMinutes} دقیقه</b>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -78,7 +127,7 @@ function ServiceSelect({ services = [], onContinue }) {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={draft.length === 0}
|
disabled={draft.length === 0}
|
||||||
onClick={() => onContinue(draft)}
|
onClick={() => onContinue(draft)}
|
||||||
className="mt-5 w-full h-[48px] rounded-xl bg-[#5559CE] text-white text-[15px] font-medium disabled:opacity-50"
|
className="mt-5 w-full h-[48px] rounded-xl bg-[var(--ap-primary)] text-white text-[15px] font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
انتخاب زمان
|
انتخاب زمان
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ function SuccessPay({ setStep }) {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full lg:w-[59%]">
|
<div className="w-full lg:w-[59%]">
|
||||||
<div
|
<div
|
||||||
className={`opacity-page bg-transparent lg:bg-[#FFF] border border-solid border-transparent lg:border-[#EFEFEF] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
|
className={`opacity-page bg-transparent lg:bg-[var(--ap-surface)] border border-solid border-transparent lg:border-[var(--ap-border)] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
|
||||||
>
|
>
|
||||||
<div className="pt-[8px] pb-[4px] grid place-items-center">
|
<div className="pt-[8px] pb-[4px] grid place-items-center">
|
||||||
<div className="flex flex-col justify-center items-center gap-[14px]">
|
<div className="flex flex-col justify-center items-center gap-[14px]">
|
||||||
<TickCircleGreenA />
|
<TickCircleGreenA />
|
||||||
<p className="text-[#3B3B3B] text-[20px] font-bold">
|
<p className="text-[var(--ap-text)] text-[20px] font-bold">
|
||||||
پرداخت شما با موفقیت انجام شد.
|
پرداخت شما با موفقیت انجام شد.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
className="text-[#3B3B3B] text-[16px] font-normal text-center mt-[24px] sm:mt-[26px] md:mt-[29px] lg:mt-[32px] mb-[24px] md:mb-[16px]"
|
className="text-[var(--ap-text)] text-[16px] font-normal text-center mt-[24px] sm:mt-[26px] md:mt-[29px] lg:mt-[32px] mb-[24px] md:mb-[16px]"
|
||||||
>
|
>
|
||||||
نوبت شما تأیید شد و پیامک تأیید برای بیمار ارسال میشود. برای نمایش نوبتها به قسمت نوبتهای من بروید.{" "}
|
نوبت شما تأیید شد و پیامک تأیید برای بیمار ارسال میشود. برای نمایش نوبتها به قسمت نوبتهای من بروید.{" "}
|
||||||
</p>
|
</p>
|
||||||
@@ -26,7 +26,7 @@ function SuccessPay({ setStep }) {
|
|||||||
variant="text"
|
variant="text"
|
||||||
className="!flex !items-center !justify-center !gap-[4px]"
|
className="!flex !items-center !justify-center !gap-[4px]"
|
||||||
>
|
>
|
||||||
<p className="text-[16px] font-medium text-[#5559CE]">
|
<p className="text-[16px] font-medium text-[var(--ap-primary)]">
|
||||||
برو به نوبت های من
|
برو به نوبت های من
|
||||||
</p>
|
</p>
|
||||||
<ArrowLeftBlueA />
|
<ArrowLeftBlueA />
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import Head from "@/components/blog/head";
|
|
||||||
|
|
||||||
const BLOG = {
|
|
||||||
title: "آلرژی دارویی",
|
|
||||||
author: "دکتر رضایی",
|
|
||||||
created: 1750000000,
|
|
||||||
reading_time: 5,
|
|
||||||
tag: [{ name: "آلرژی" }, { name: "قلب و عروق" }],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("سربرگ مقاله", () => {
|
|
||||||
it("برچسب «مخصوص شهر» را نشان نمیدهد", () => {
|
|
||||||
render(<Head data={BLOG} />);
|
|
||||||
|
|
||||||
expect(screen.queryByText("مخصوص شهر:")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("نویسنده و تاریخ انتشار سر جایشان میمانند", () => {
|
|
||||||
render(<Head data={BLOG} />);
|
|
||||||
|
|
||||||
expect(screen.getByText("نویسنده:")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("تاریخ انتشار:")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("هر تگ به لیست مقالههای همان تگ لینک میشود", () => {
|
|
||||||
render(<Head data={BLOG} />);
|
|
||||||
|
|
||||||
const links = screen.getAllByRole("link", { name: "قلب و عروق" });
|
|
||||||
const url = new URL(links[0].getAttribute("href"), "https://yasuj-nobat.ir");
|
|
||||||
|
|
||||||
expect(links).toHaveLength(1);
|
|
||||||
expect(url.pathname).toBe("/blogs");
|
|
||||||
// قرارداد واقعی همان چیزی است که `searchParams.get("tag")` میخواند،
|
|
||||||
// نه شکل دقیق encode — شکل شیئیِ href فاصله را `+` میکند و بردکرامب `%20`.
|
|
||||||
expect(url.searchParams.get("tag")).toBe("قلب و عروق");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("تگ حاوی & با پارامتر دوم اشتباه گرفته نمیشود", () => {
|
|
||||||
render(<Head data={{ ...BLOG, tag: [{ name: "قلب & عروق" }] }} />);
|
|
||||||
|
|
||||||
const links = screen.getAllByRole("link", { name: "قلب & عروق" });
|
|
||||||
const url = new URL(links[0].getAttribute("href"), "https://yasuj-nobat.ir");
|
|
||||||
|
|
||||||
expect(url.searchParams.get("tag")).toBe("قلب & عروق");
|
|
||||||
expect(Array.from(url.searchParams.keys())).toEqual(["tag"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("مقالهٔ بدون تگ هیچ فهرست تگی رندر نمیکند", () => {
|
|
||||||
const { container } = render(<Head data={{ ...BLOG, tag: [] }} />);
|
|
||||||
|
|
||||||
expect(container.querySelector("ul")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,7 +4,7 @@ import { convertTimestampToJalali } from "@/helper";
|
|||||||
const crumbClass =
|
const crumbClass =
|
||||||
"text-[#9B9B9B] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal";
|
"text-[#9B9B9B] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal";
|
||||||
|
|
||||||
function Head({ data }) {
|
function Head({ data, cityName }) {
|
||||||
const firstTag = data?.tag?.[0];
|
const firstTag = data?.tag?.[0];
|
||||||
const createdDate = data?.created ? convertTimestampToJalali(data.created) : "";
|
const createdDate = data?.created ? convertTimestampToJalali(data.created) : "";
|
||||||
// normalizeBlog تگها را به [{name}] تبدیل میکند؛ نامِ خالی حذف میشود.
|
// normalizeBlog تگها را به [{name}] تبدیل میکند؛ نامِ خالی حذف میشود.
|
||||||
@@ -44,6 +44,18 @@ function Head({ data }) {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex mt-[16px] sm:mt-[18px] md:mt-[21px] lg:mt-[24px] items-center justify-start gap-[35px]">
|
<div className="flex mt-[16px] sm:mt-[18px] md:mt-[21px] lg:mt-[24px] items-center justify-start gap-[35px]">
|
||||||
|
{/* شهر پست در HTML قابلمشاهده — نه فقط در متادیتا. برچسب با همان
|
||||||
|
تایپوگرافی «نویسنده/تاریخ» تا با بقیهٔ سربرگ همخانواده بماند. */}
|
||||||
|
{cityName && (
|
||||||
|
<div className="flex items-center justify-start gap-1">
|
||||||
|
<p className="text-[#9B9B9B] text-[11px] md:text-[13px] lg:text-[14px] font-normal">
|
||||||
|
مخصوص شهر:
|
||||||
|
</p>
|
||||||
|
<p className="text-[#9B9B9B] text-[11px] md:text-[14px] lg:text-[16px] font-medium">
|
||||||
|
{cityName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{data?.author && (
|
{data?.author && (
|
||||||
<div className="flex items-center justify-start gap-1">
|
<div className="flex items-center justify-start gap-1">
|
||||||
<p className="text-[#9B9B9B] text-[11px] md:text-[13px] lg:text-[14px] font-normal">
|
<p className="text-[#9B9B9B] text-[11px] md:text-[13px] lg:text-[14px] font-normal">
|
||||||
@@ -76,18 +88,15 @@ function Head({ data }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* فهرست کامل تگها — تا این تغییر فقط تگ اول در بردکرامب دیده میشد.
|
{/* فهرست کامل تگها — تا این تغییر فقط تگ اول در بردکرامب دیده میشد.
|
||||||
مقصد هر چیپ همان لیست مقالههاست که `?tag=` را میخواند؛ شکل شیئیِ href
|
صفحهٔ فیلتر تگ وجود ندارد، پس چیپها بدون لینک رندر میشوند. */}
|
||||||
تگِ حاوی فاصله یا & را هم درست encode میکند. */}
|
|
||||||
{tags.length > 0 && (
|
{tags.length > 0 && (
|
||||||
<ul className="flex flex-wrap items-center justify-start gap-2 mt-[12px] md:mt-[16px]">
|
<ul className="flex flex-wrap items-center justify-start gap-2 mt-[12px] md:mt-[16px]">
|
||||||
{tags.map((tag, idx) => (
|
{tags.map((tag, idx) => (
|
||||||
<li key={`${tag}-${idx}`}>
|
<li
|
||||||
<Link
|
key={`${tag}-${idx}`}
|
||||||
href={{ pathname: "/blogs", query: { tag } }}
|
className="bg-[#F5F5F5] text-[#525252] rounded-full px-[12px] py-[4px] text-[11px] md:text-[13px] font-medium"
|
||||||
className="block bg-[#F5F5F5] text-[#525252] rounded-full px-[12px] py-[4px] text-[11px] md:text-[13px] font-medium hover:bg-[#E8E8E8] hover:text-[#3B3B3B] transition-colors"
|
>
|
||||||
>
|
{tag}
|
||||||
{tag}
|
|
||||||
</Link>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import Detail from "./detail";
|
|||||||
import Head from "./head";
|
import Head from "./head";
|
||||||
import RelatedContent from "./relatedContent";
|
import RelatedContent from "./relatedContent";
|
||||||
|
|
||||||
function BlogPage({ blog, blogs }) {
|
function BlogPage({ blog, blogs, cityName }) {
|
||||||
return (
|
return (
|
||||||
<div className="padding-responsive pt-[84px] sm:pt-[110px] md:pt-[140px] lg:pt-[168px]">
|
<div className="padding-responsive pt-[84px] sm:pt-[110px] md:pt-[140px] lg:pt-[168px]">
|
||||||
<Head data={blog} />
|
<Head data={blog} cityName={cityName} />
|
||||||
<div className="flex flex-col gap-y-[32px] lg:flex-row items-start justify-center gap-[24px] mt-[16px] sm:mt-[19px] md:mt-[21px] lg:mt-[24px]">
|
<div className="flex flex-col gap-y-[32px] lg:flex-row items-start justify-center gap-[24px] mt-[16px] sm:mt-[19px] md:mt-[21px] lg:mt-[24px]">
|
||||||
<Detail data={blog} />
|
<Detail data={blog} />
|
||||||
<RelatedContent data={blogs} />
|
<RelatedContent data={blogs} />
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ function Item({ data, idx, loading }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex w-full relative items-start justify-start gap-[8px] py-[12px]">
|
<li className="flex w-full relative items-start justify-start gap-[8px] py-[12px]">
|
||||||
{/* CustomLoading ظرف نمیسازد، پس فرزند مستقیمِ فلکس همین لینک است؛
|
|
||||||
بدون shrink-0 تیتر بلند عرض عکس را میبلعد و ارتفاعش ثابت میماند. */}
|
|
||||||
<CustomLoading width={80} height={72} loading={loading}>
|
<CustomLoading width={80} height={72} loading={loading}>
|
||||||
<Link href={`/blog/${data.uuid}`} className="block shrink-0">
|
<Link href={`/blog/${data.uuid}`}>
|
||||||
<img
|
<img
|
||||||
className=" w-[64px] sm:w-[73px] md:w-[85px] lg:w-[97px] h-[56px] sm:h-[64px] md:h-[75px] lg:h-[88px] rounded-[8px] overflow-hidden object-cover "
|
className=" w-[64px] sm:w-[73px] md:w-[85px] lg:w-[97px] h-[56px] sm:h-[64px] md:h-[75px] lg:h-[88px] rounded-[8px] overflow-hidden object-cover "
|
||||||
src={cover}
|
src={cover}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import Item from "@/components/blog/relatedContent/Item";
|
|
||||||
|
|
||||||
const DATA = {
|
|
||||||
uuid: "8f1c0f2e-0000-4000-8000-000000000001",
|
|
||||||
title: "تیتری که بسیار بلند است و باید ستون متن را پر کند بدون آنکه عکس را باریک کند",
|
|
||||||
created: 1750000000,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("آیتم مطالب مرتبط", () => {
|
|
||||||
it("ظرف عکس در فلکس فشرده نمیشود", () => {
|
|
||||||
render(<Item data={DATA} idx={0} loading={false} />);
|
|
||||||
|
|
||||||
const image = screen.getByRole("img", { name: DATA.title });
|
|
||||||
|
|
||||||
// jsdom چیدمان را اندازه نمیگیرد؛ آنچه قابل تست است همین قرارداد کلاس است.
|
|
||||||
expect(image.closest("a")).toHaveClass("shrink-0");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ستون متن اجازهٔ کوچک شدن دارد تا سرریز نکند", () => {
|
|
||||||
const { container } = render(<Item data={DATA} idx={0} loading={false} />);
|
|
||||||
|
|
||||||
expect(container.querySelector("li > div")).toHaveClass("min-w-0");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import CustomLoading from "@/app/component/loading/Custom";
|
import CustomLoading from "@/app/component/loading/Custom";
|
||||||
import ArrowLeftList from "@/components/icons/ArrowLeftList";
|
import ArrowLeftList from "@/components/icons/ArrowLeftList";
|
||||||
import { imageUrl } from "@/helper";
|
|
||||||
import { Button } from "@mui/material";
|
import { Button } from "@mui/material";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRef, useEffect, useState } from "react";
|
import { useRef, useEffect, useState } from "react";
|
||||||
@@ -65,10 +64,8 @@ function ListBime({ data }) {
|
|||||||
className="p-[16px] border border-[#EFEFEF] h-[175px] flex flex-col items-center justify-center gap-[20px]"
|
className="p-[16px] border border-[#EFEFEF] h-[175px] flex flex-col items-center justify-center gap-[20px]"
|
||||||
style={{ minWidth: itemWidth }}
|
style={{ minWidth: itemWidth }}
|
||||||
>
|
>
|
||||||
{/* `logo_url` مسیر نسبی روی خودِ API است و باید با دامنهٔ API کامل شود.
|
|
||||||
شکل قدیمی `logo[0].url` برای رکوردهای قدیمی نگه داشته شده. */}
|
|
||||||
<Image
|
<Image
|
||||||
src={imageUrl(item.logo_url || item.logo?.[0]?.url, "/assets/images/logo.png")}
|
src={item.logo?.[0]?.url || "/assets/images/logo.png"}
|
||||||
height={92}
|
height={92}
|
||||||
width={92}
|
width={92}
|
||||||
alt="bime"
|
alt="bime"
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import ListBime from './ListBime';
|
|
||||||
|
|
||||||
// next/image به src خام دست نمیزند تا بشود آن را assert کرد.
|
|
||||||
vi.mock('next/image', () => ({
|
|
||||||
default: ({ src, alt }) => <img src={src} alt={alt} />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const LOGO = '/uploads/insurances/logo/2026-08/tamin.png';
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.NEXT_PUBLIC_API_URL = 'https://api.example.ir';
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ListBime', () => {
|
|
||||||
it('لوگوی بیمه را با دامنهٔ API کامل میکند', () => {
|
|
||||||
render(<ListBime data={{ list_bime: [{ name: 'تامین اجتماعی', logo_url: LOGO }] }} />);
|
|
||||||
|
|
||||||
expect(screen.getByAltText('bime')).toHaveAttribute('src', `https://api.example.ir${LOGO}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('شکل قدیمی logo[0].url هنوز کار میکند', () => {
|
|
||||||
render(<ListBime data={{ list_bime: [{ name: 'بیمه ایران', logo: [{ url: LOGO }] }] }} />);
|
|
||||||
|
|
||||||
expect(screen.getByAltText('bime')).toHaveAttribute('src', `https://api.example.ir${LOGO}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('بیمهٔ بدون لوگو به تصویر پیشفرض میافتد', () => {
|
|
||||||
render(<ListBime data={{ list_bime: [{ name: 'بیمه بیلوگو' }] }} />);
|
|
||||||
|
|
||||||
expect(screen.getByAltText('bime')).toHaveAttribute('src', '/assets/images/logo.png');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -14,7 +14,7 @@ import cities from "@/data/city.json";
|
|||||||
import { isRootCity } from "@/lib/rootCity";
|
import { isRootCity } from "@/lib/rootCity";
|
||||||
import { resolveCityDisplayName } from "@/lib/domainHelpers";
|
import { resolveCityDisplayName } from "@/lib/domainHelpers";
|
||||||
|
|
||||||
function ClinicsPage({ clinics, matchedCity, matchedState, origin, intro }) {
|
function ClinicsPage({ clinics, matchedCity, matchedState, origin }) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [page, setPage] = useState(clinics?.meta?.currentPage || 1);
|
const [page, setPage] = useState(clinics?.meta?.currentPage || 1);
|
||||||
const [filteredClinics, setFilteredClinics] = useState(clinics?.data);
|
const [filteredClinics, setFilteredClinics] = useState(clinics?.data);
|
||||||
@@ -49,11 +49,6 @@ function ClinicsPage({ clinics, matchedCity, matchedState, origin, intro }) {
|
|||||||
setFilteredClinics={setFilteredClinics}
|
setFilteredClinics={setFilteredClinics}
|
||||||
/>
|
/>
|
||||||
</HeadPageList>
|
</HeadPageList>
|
||||||
{intro && (
|
|
||||||
<p className="padding-responsive text-[#525252] text-[14px] md:text-[16px] font-normal leading-8 mt-[24px] text-justify">
|
|
||||||
{intro}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<List
|
<List
|
||||||
limit={12}
|
limit={12}
|
||||||
page={page}
|
page={page}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Links from "./Links";
|
import Links from "./Links";
|
||||||
|
|
||||||
function Call({ matchedCity, isDefault, cityIntro }) {
|
function Call({ matchedCity, isDefault }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`lg:min-w-[calc(50%_+_70px)] bg-banner-about !bg-center !bg-no-repeat !bg-cover h-fit pt-[16px] sm:pt-[29px] md:pt-[42px] lg:pt-[55px] bg-contact rounded-[8px] pb-[30px] sm:pb-[42px] md:pb-[55px] overflow-hidden lg:pb-[171px] px-[16px] sm:px-[27px] md:px-[39px] lg:px-[48px] ${isDefault ? "lg:pl-[18%]" : "w-full"}`}
|
className={`lg:min-w-[calc(50%_+_70px)] bg-banner-about !bg-center !bg-no-repeat !bg-cover h-fit pt-[16px] sm:pt-[29px] md:pt-[42px] lg:pt-[55px] bg-contact rounded-[8px] pb-[30px] sm:pb-[42px] md:pb-[55px] overflow-hidden lg:pb-[171px] px-[16px] sm:px-[27px] md:px-[39px] lg:px-[48px] ${isDefault ? "lg:pl-[18%]" : "w-full"}`}
|
||||||
@@ -9,11 +9,6 @@ function Call({ matchedCity, isDefault, cityIntro }) {
|
|||||||
<h2 className="text-[#FAFAFA] leading-[26px] text-[14px] md:text-[16px] font-normal mt-6">
|
<h2 className="text-[#FAFAFA] leading-[26px] text-[14px] md:text-[16px] font-normal mt-6">
|
||||||
{matchedCity?.footer_description || "برای ارتباط با ما از راه های زیر استفاده کنید"}
|
{matchedCity?.footer_description || "برای ارتباط با ما از راه های زیر استفاده کنید"}
|
||||||
</h2>
|
</h2>
|
||||||
{cityIntro && (
|
|
||||||
<p className="text-[#E5E5F5] leading-[26px] text-[13px] md:text-[15px] font-normal mt-4">
|
|
||||||
{cityIntro}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Links matchedCity={matchedCity} />
|
<Links matchedCity={matchedCity} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
import { getStateInfo } from "@/lib/getStateInfo";
|
import { getStateInfo } from "@/lib/getStateInfo";
|
||||||
import { buildContactIntro } from "@/lib/staticPageIntro";
|
|
||||||
import Call from "./call/Call";
|
import Call from "./call/Call";
|
||||||
import Connect from "./Connect";
|
import Connect from "./Connect";
|
||||||
|
|
||||||
async function ContactUsPage() {
|
async function ContactUsPage() {
|
||||||
const { matchedCity, matchedState, isRoot } = await getStateInfo();
|
const { matchedCity, isRoot } = await getStateInfo();
|
||||||
// مثل «درباره ما»: این صفحه هم self-canonical شد و بدون متن شهری، تلفن و ایمیل
|
|
||||||
// یکسانِ ۳۵ دامنه دوباره duplicate میساخت.
|
|
||||||
const cityIntro = isRoot
|
|
||||||
? ""
|
|
||||||
: buildContactIntro(
|
|
||||||
matchedCity?.name,
|
|
||||||
matchedState?.name,
|
|
||||||
matchedCity?.site_name,
|
|
||||||
matchedCity?.slogan
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full pt-[24px] sm:pt-[56px] md:pt-[88px] lg:pt-[120px] mt-[56px] padding-responsive flex flex-col lg:flex-row items-center justify-between h-fit">
|
<div className="w-full pt-[24px] sm:pt-[56px] md:pt-[88px] lg:pt-[120px] mt-[56px] padding-responsive flex flex-col lg:flex-row items-center justify-between h-fit">
|
||||||
<Call matchedCity={matchedCity} isDefault={isRoot} cityIntro={cityIntro} />
|
<Call matchedCity={matchedCity} isDefault={isRoot} />
|
||||||
{isRoot && <Connect />}
|
{isRoot && <Connect />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,6 +55,25 @@ function Card({ data, loading, setIsTurnsDetails }) {
|
|||||||
</p>
|
</p>
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
{/* فیلدهای حالت نوبتدهی سرویسی. شرط اجباری است: نوبت اسلاتی اینها را ندارد و
|
||||||
|
بدون شرط، کارتِ همهٔ نوبتهای اسلاتی میشکند — یعنی کل پنل کاربر. */}
|
||||||
|
{data.service_items?.length > 0 && (
|
||||||
|
<li className="flex items-start justify-between gap-[8px]">
|
||||||
|
<p className="text-[#7E7E7E] text-[14px] font-normal shrink-0">سرویس:</p>
|
||||||
|
<p className="text-[#525252] text-[14px] font-medium text-left">
|
||||||
|
{data.service_items.map((s) => s.name).join("، ")}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{!data.is_reserve && data.service_total_minutes ? (
|
||||||
|
<li className="flex items-center justify-between">
|
||||||
|
<p className="text-[#7E7E7E] text-[14px] font-normal">مدت نوبت:</p>
|
||||||
|
<p className="text-[#525252] text-[14px] font-medium">
|
||||||
|
{data.service_total_minutes} دقیقه
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-[12px] flex items-center justify-end px-[12px]">
|
<div className="mt-[12px] flex items-center justify-end px-[12px]">
|
||||||
<TextLoading loading={loading} width={80} height={25}>
|
<TextLoading loading={loading} width={80} height={25}>
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import Card from "./Card";
|
||||||
|
|
||||||
|
// آیکن و لودینگها به این تست ربطی ندارند؛ فقط باید کودکشان را رندر کنند.
|
||||||
|
vi.mock("@/app/component/loading/Text", () => ({
|
||||||
|
default: ({ children }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
vi.mock("@/app/component/loading/Circular", () => ({
|
||||||
|
default: ({ children }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/icons/ArrowLeftCardD", () => ({ default: () => null }));
|
||||||
|
vi.mock("next/image", () => ({ default: () => null }));
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
uuid: "ap1",
|
||||||
|
status: "confirmed",
|
||||||
|
slot_start: 1785567600,
|
||||||
|
doctor: { name: "دکتر تست" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderCard(data) {
|
||||||
|
return render(<Card data={{ ...base, ...data }} loading={false} setIsTurnsDetails={() => {}} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Card نوبت — فیلدهای حالت سرویسی", () => {
|
||||||
|
// ── ✅ موفق ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it("نام همهٔ سرویسها و مدت نوبت را نشان میدهد", () => {
|
||||||
|
renderCard({
|
||||||
|
service_items: [{ uuid: "s1", name: "لیزر صورت" }, { uuid: "s2", name: "لیزر بیکینی" }],
|
||||||
|
service_total_minutes: 35,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("سرویس:")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("لیزر صورت، لیزر بیکینی")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("مدت نوبت:")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("35 دقیقه")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ❌ خط سرخ: نوبت اسلاتی ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it("نوبت اسلاتی هیچکدام از دو ردیف را نمیگیرد و کرش نمیکند", () => {
|
||||||
|
renderCard({ service_items: [], service_total_minutes: null });
|
||||||
|
|
||||||
|
expect(screen.queryByText("سرویس:")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("مدت نوبت:")).not.toBeInTheDocument();
|
||||||
|
// ردیفهای موجود سر جایشاناند.
|
||||||
|
expect(screen.getByText("تاریخ نوبت:")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("ساعت نوبت:")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("فیلدهای سرویسی کاملاً غایب (بکاند قدیمی) هم کرش نمیدهد", () => {
|
||||||
|
renderCard({});
|
||||||
|
|
||||||
|
expect(screen.queryByText("سرویس:")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("تاریخ نوبت:")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it("نوبت رزرو سرویس را نشان میدهد ولی مدت را نه (زمان ندارد)", () => {
|
||||||
|
renderCard({
|
||||||
|
is_reserve: true,
|
||||||
|
service_items: [{ uuid: "s1", name: "لیزر" }],
|
||||||
|
service_total_minutes: 45,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("لیزر")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("مدت نوبت:")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("سرویس بدون مدت → فقط ردیف سرویس", () => {
|
||||||
|
renderCard({ service_items: [{ uuid: "s1", name: "مشاوره" }], service_total_minutes: null });
|
||||||
|
|
||||||
|
expect(screen.getByText("مشاوره")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("مدت نوبت:")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("مدت صفر ردیف نمیسازد", () => {
|
||||||
|
renderCard({ service_items: [{ uuid: "s1", name: "مشاوره" }], service_total_minutes: 0 });
|
||||||
|
|
||||||
|
expect(screen.queryByText("مدت نوبت:")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,9 @@ function Turns({ user, setIsTurnsDetails, loading }) {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [selectedAppointment, setSelectedAppointment] = useState(null);
|
const [selectedAppointment, setSelectedAppointment] = useState(null);
|
||||||
|
// تغییر عمدیِ کاربر (لغو یا جابهجایی) باید فهرست را تازه کند؛ وگرنه نوبتی که همین
|
||||||
|
// حالا لغو شد هنوز «تأییدشده» نشان داده میشود.
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAppointments = async () => {
|
const fetchAppointments = async () => {
|
||||||
@@ -51,7 +54,7 @@ function Turns({ user, setIsTurnsDetails, loading }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchAppointments();
|
fetchAppointments();
|
||||||
}, [page, status]);
|
}, [page, status, reloadKey]);
|
||||||
|
|
||||||
const handlePageChange = (event, value) => {
|
const handlePageChange = (event, value) => {
|
||||||
setPage(value);
|
setPage(value);
|
||||||
@@ -68,6 +71,10 @@ function Turns({ user, setIsTurnsDetails, loading }) {
|
|||||||
<IsTurnsDetails
|
<IsTurnsDetails
|
||||||
appointmentData={selectedAppointment}
|
appointmentData={selectedAppointment}
|
||||||
setIsTurnsDetails={setSelectedAppointment}
|
setIsTurnsDetails={setSelectedAppointment}
|
||||||
|
onChanged={() => {
|
||||||
|
setSelectedAppointment(null);
|
||||||
|
setReloadKey((k) => k + 1);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,33 +1,39 @@
|
|||||||
import { Button } from "@mui/material";
|
import { Button } from "@mui/material";
|
||||||
import ModalDeleteComment from "./modal";
|
import ModalDeleteComment from "./modal";
|
||||||
import EditTurnsD from "@/components/icons/EditTurnsD";
|
import RescheduleModal from "./modal/RescheduleModal";
|
||||||
import ArrowLeftD from "@/components/icons/ArrowLeftD";
|
|
||||||
import DownloadD from "@/components/icons/DownloadD";
|
import DownloadD from "@/components/icons/DownloadD";
|
||||||
|
|
||||||
function ButtonData({ loading, onDownload }) {
|
/** نوبتی که گذشته یا از قبل لغو شده، نه لغو میشود نه جابهجا. */
|
||||||
|
function isOpen(appointment) {
|
||||||
|
if (!appointment?.uuid) return false;
|
||||||
|
if (String(appointment.status ?? "").startsWith("cancelled")) return false;
|
||||||
|
|
||||||
|
return (appointment.slot_start ?? 0) > Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonData({ loading, onDownload, appointment, onChanged }) {
|
||||||
|
const actionable = isOpen(appointment);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex mt-[48px] md:mt-0 items-center justify-center md:justify-end gap-[16px] sm:gap-[16px] md:gap-[20px] lg:gap-[24px]">
|
<div className="flex mt-[48px] md:mt-0 items-center justify-center md:justify-end gap-[16px] sm:gap-[16px] md:gap-[20px] lg:gap-[24px]">
|
||||||
{/* <Button
|
{onDownload && (
|
||||||
className="!rounded-[4px] !border !border-solid !border-[#D7D7D7] !shadow-none !gap-[4px] !py-[6px] md:!py-[7px] lg:!py-[8px] !px-[12px]"
|
<Button
|
||||||
disabled={loading}
|
className="!rounded-[4px] !border !border-solid !border-[#D7D7D7] !shadow-none !gap-[4px] !py-[6px] md:!py-[7px] lg:!py-[8px] !px-[12px]"
|
||||||
variant="outlined"
|
disabled={loading}
|
||||||
onClick={onDownload}
|
variant="outlined"
|
||||||
>
|
onClick={onDownload}
|
||||||
<DownloadD />
|
>
|
||||||
دانلود اطلاعات نوبت
|
<DownloadD />
|
||||||
</Button>
|
دانلود اطلاعات نوبت
|
||||||
<ModalDeleteComment loading={loading} />
|
</Button>
|
||||||
<Button
|
)}
|
||||||
className="!rounded-[4px] !shadow-none !gap-[4px] !py-[6px] md:!py-[7px] lg:!py-[8px] !px-[12px]"
|
|
||||||
disabled={loading}
|
{actionable && (
|
||||||
variant="contained"
|
<>
|
||||||
>
|
<ModalDeleteComment loading={loading} appointment={appointment} onDone={onChanged} />
|
||||||
<EditTurnsD />
|
<RescheduleModal loading={loading} appointment={appointment} onDone={onChanged} />
|
||||||
ویرایش نوبت
|
</>
|
||||||
<div className="mr-[4px]">
|
)}
|
||||||
<ArrowLeftD />
|
|
||||||
</div>
|
|
||||||
</Button> */}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,29 @@ function DetailLg({ appointmentData, loading }) {
|
|||||||
</div>
|
</div>
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
</li>
|
</li>
|
||||||
|
{/* حالت نوبتدهی سرویسی. شرط اجباری: نوبت اسلاتی این فیلدها را ندارد. */}
|
||||||
|
{appointmentData?.service_items?.length > 0 && (
|
||||||
|
<li className="flex flex-wrap items-center justify-start gap-y-[12px] gap-[32px]">
|
||||||
|
<TextLoading loading={loading} width={200} height={20}>
|
||||||
|
<p className="text-[#616161] text-[14px] lg:text-[16px] font-normal">
|
||||||
|
سرویس:{" "}
|
||||||
|
<span className="text-[#2F2F2F] text-[14px] lg:text-[16px] font-medium">
|
||||||
|
{appointmentData.service_items.map((s) => s.name).join("، ")}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</TextLoading>
|
||||||
|
{!appointmentData.is_reserve && appointmentData.service_total_minutes ? (
|
||||||
|
<TextLoading loading={loading} width={110} height={20}>
|
||||||
|
<p className="text-[#616161] text-[14px] lg:text-[16px] font-normal">
|
||||||
|
مدت نوبت:{" "}
|
||||||
|
<span className="text-[#2F2F2F] text-[14px] lg:text-[16px] font-medium">
|
||||||
|
{appointmentData.service_total_minutes} دقیقه
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</TextLoading>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
<li className="flex flex-wrap items-center justify-start gap-y-[12px] gap-[32px]">
|
<li className="flex flex-wrap items-center justify-start gap-y-[12px] gap-[32px]">
|
||||||
<TextLoading loading={loading} width={250} height={20}>
|
<TextLoading loading={loading} width={250} height={20}>
|
||||||
<div className="flex items-start justify-start gap-[4px]">
|
<div className="flex items-start justify-start gap-[4px]">
|
||||||
@@ -120,20 +143,17 @@ function DetailLg({ appointmentData, loading }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
</li>
|
</li>
|
||||||
{/* بعضی مطبها شماره ثبت نکردهاند؛ «تلفن:» خالی بدتر از نبودنِ سطر است. */}
|
<TextLoading loading={loading} width={120} height={18}>
|
||||||
{appointmentData?.address?.telephone && (
|
<li className="flex items-center justify-start gap-[4px]">
|
||||||
<TextLoading loading={loading} width={120} height={18}>
|
<CallD />
|
||||||
<li className="flex items-center justify-start gap-[4px]">
|
<p className="text-[#616161] text-[14px] lg:text-[16px] font-normal">
|
||||||
<CallD />
|
تلفن:{" "}
|
||||||
<p className="text-[#616161] text-[14px] lg:text-[16px] font-normal">
|
<span className="text-[#2F2F2F] text-[14px] lg:text-[16px] font-normal">
|
||||||
تلفن:{" "}
|
{appointmentData?.address?.telephone}
|
||||||
<span className="text-[#2F2F2F] text-[14px] lg:text-[16px] font-normal">
|
</span>
|
||||||
{appointmentData.address.telephone}
|
</p>
|
||||||
</span>
|
</li>
|
||||||
</p>
|
</TextLoading>
|
||||||
</li>
|
|
||||||
</TextLoading>
|
|
||||||
)}
|
|
||||||
</ul>
|
</ul>
|
||||||
{/* <Button
|
{/* <Button
|
||||||
className="!px-[12px] !hidden md:!flex !mt-[24px] !py-[8px] !border !border-solid !border-[#D7D7D7] !rounded-[4px] !w-fit !items-center !justify-center !gap-[4px]"
|
className="!px-[12px] !hidden md:!flex !mt-[24px] !py-[8px] !border !border-solid !border-[#D7D7D7] !rounded-[4px] !w-fit !items-center !justify-center !gap-[4px]"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { convertTimestampToJalali, convertTimestampToTime } from "@/helper";
|
|||||||
import html2canvas from "html2canvas";
|
import html2canvas from "html2canvas";
|
||||||
import jsPDF from "jspdf";
|
import jsPDF from "jspdf";
|
||||||
|
|
||||||
function DetailSm({ appointmentData, loading }) {
|
function DetailSm({ appointmentData, loading, onChanged }) {
|
||||||
const detailsRef = useRef(null);
|
const detailsRef = useRef(null);
|
||||||
|
|
||||||
const handleDownloadPDF = async () => {
|
const handleDownloadPDF = async () => {
|
||||||
@@ -85,6 +85,31 @@ function DetailSm({ appointmentData, loading }) {
|
|||||||
</p>
|
</p>
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
</li>
|
</li>
|
||||||
|
{/* حالت نوبتدهی سرویسی. شرط اجباری: نوبت اسلاتی این فیلدها را ندارد. */}
|
||||||
|
{appointmentData?.service_items?.length > 0 && (
|
||||||
|
<li className="flex flex-wrap gap-x-[12px] items-start w-full justify-between mt-[12px]">
|
||||||
|
<TextLoading loading={loading} width={80} height={16}>
|
||||||
|
<p className="text-[#7E7E7E] text-[16px] font-normal shrink-0">سرویس:</p>
|
||||||
|
</TextLoading>
|
||||||
|
<TextLoading loading={loading} width={140} height={16}>
|
||||||
|
<p className="text-[#525252] text-[16px] font-medium text-left">
|
||||||
|
{appointmentData.service_items.map((s) => s.name).join("، ")}
|
||||||
|
</p>
|
||||||
|
</TextLoading>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{!appointmentData?.is_reserve && appointmentData?.service_total_minutes ? (
|
||||||
|
<li className="flex flex-wrap items-center w-full justify-between mt-[12px]">
|
||||||
|
<TextLoading loading={loading} width={80} height={16}>
|
||||||
|
<p className="text-[#7E7E7E] text-[16px] font-normal">مدت نوبت:</p>
|
||||||
|
</TextLoading>
|
||||||
|
<TextLoading loading={loading} width={60} height={16}>
|
||||||
|
<p className="text-[#525252] text-[16px] font-medium">
|
||||||
|
{appointmentData.service_total_minutes} دقیقه
|
||||||
|
</p>
|
||||||
|
</TextLoading>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
<span className="block w-full h-px bg-[#EFEFEF] mt-[12px] mb-[16px]"></span>
|
<span className="block w-full h-px bg-[#EFEFEF] mt-[12px] mb-[16px]"></span>
|
||||||
<li className="flex flex-wrap gap-x-[12px] gap-y-[12px] items-center w-full justify-between">
|
<li className="flex flex-wrap gap-x-[12px] gap-y-[12px] items-center w-full justify-between">
|
||||||
<TextLoading loading={loading} width={200} height={16}>
|
<TextLoading loading={loading} width={200} height={16}>
|
||||||
@@ -110,15 +135,18 @@ function DetailSm({ appointmentData, loading }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
</li>
|
</li>
|
||||||
{appointmentData?.address?.telephone && (
|
<TextLoading loading={loading} width={100} height={18}>
|
||||||
<TextLoading loading={loading} width={100} height={18}>
|
<p className="text-[#7E7E7E] text-[14px] font-normal mt-[12px]">
|
||||||
<p className="text-[#7E7E7E] text-[14px] font-normal mt-[12px]">
|
تلفن: <span className="text-[#616161]">{appointmentData?.address?.telephone}</span>
|
||||||
تلفن: <span className="text-[#616161]">{appointmentData.address.telephone}</span>
|
</p>
|
||||||
</p>
|
</TextLoading>
|
||||||
</TextLoading>
|
|
||||||
)}
|
|
||||||
</ul>
|
</ul>
|
||||||
<ButtonData loading={loading} onDownload={handleDownloadPDF} />
|
<ButtonData
|
||||||
|
loading={loading}
|
||||||
|
onDownload={handleDownloadPDF}
|
||||||
|
appointment={appointmentData}
|
||||||
|
onChanged={onChanged}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Button } from "@mui/material";
|
|||||||
import ButtonData from "./ButtonData";
|
import ButtonData from "./ButtonData";
|
||||||
import TextLoading from "@/app/component/loading/Text";
|
import TextLoading from "@/app/component/loading/Text";
|
||||||
|
|
||||||
function Head({ setIsTurnsDetails }) {
|
function Head({ setIsTurnsDetails, appointment, onChanged }) {
|
||||||
return (
|
return (
|
||||||
<div className="hidden md:flex flex-wrap gap-x-[24px] gap-y-[12px] items-center justify-between">
|
<div className="hidden md:flex flex-wrap gap-x-[24px] gap-y-[12px] items-center justify-between">
|
||||||
<div className="flex items-center justify-start gap-[12px] sm:gap-[16px] md:gap-[20px] lg:gap-[24px]">
|
<div className="flex items-center justify-start gap-[12px] sm:gap-[16px] md:gap-[20px] lg:gap-[24px]">
|
||||||
@@ -19,7 +19,7 @@ function Head({ setIsTurnsDetails }) {
|
|||||||
</p>
|
</p>
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
</div>
|
</div>
|
||||||
<ButtonData loading={false} />
|
<ButtonData loading={false} appointment={appointment} onChanged={onChanged} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ import DetailLg from "./DetailLg";
|
|||||||
import DetailSm from "./DetailSm";
|
import DetailSm from "./DetailSm";
|
||||||
import Head from "./Head";
|
import Head from "./Head";
|
||||||
|
|
||||||
function IsTurnsDetails({ appointmentData, setIsTurnsDetails }) {
|
function IsTurnsDetails({ appointmentData, setIsTurnsDetails, onChanged }) {
|
||||||
return (
|
return (
|
||||||
<div className="md:py-[20px] md:px-[24px] opacity-page">
|
<div className="md:py-[20px] md:px-[24px] opacity-page">
|
||||||
<Head setIsTurnsDetails={setIsTurnsDetails} />
|
<Head
|
||||||
<DetailLg appointmentData={appointmentData} loading={false} />
|
setIsTurnsDetails={setIsTurnsDetails}
|
||||||
<DetailSm appointmentData={appointmentData} loading={false} />
|
appointment={appointmentData}
|
||||||
|
onChanged={onChanged}
|
||||||
|
/>
|
||||||
|
<DetailLg appointmentData={appointmentData} loading={false} onChanged={onChanged} />
|
||||||
|
<DetailSm appointmentData={appointmentData} loading={false} onChanged={onChanged} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Box, Button, Modal } from "@mui/material";
|
||||||
|
import moment from "moment-jalaali";
|
||||||
|
import { styleDefault } from "@/mui";
|
||||||
|
import CloseModalD from "@/components/icons/CloseModalD";
|
||||||
|
import { request } from "@/services/response";
|
||||||
|
import { adaptServiceSlots } from "@/lib/appointmentSlots";
|
||||||
|
|
||||||
|
const DAYS_AHEAD = 14;
|
||||||
|
|
||||||
|
/** چهارده روز آینده — نوبتی که بیمار خودش جابهجا میکند، ماهها جلوتر نمیرود. */
|
||||||
|
function nextDays() {
|
||||||
|
return Array.from({ length: DAYS_AHEAD }, (_, i) => {
|
||||||
|
const day = moment().add(i, "day");
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: day.format("YYYY-MM-DD"),
|
||||||
|
label: day.format("jD jMMMM"),
|
||||||
|
weekday: day.format("dddd"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جابهجایی نوبت از پنل کاربر.
|
||||||
|
*
|
||||||
|
* مدت را **سرور** حساب میکند: بیمار فقط زمان شروع میفرستد. اگر فرانت مدت را میفرستاد،
|
||||||
|
* دو محاسبهٔ موازی داشتیم و روزی که تعرفه یا مدت سرویس عوض میشد، نوبت با مدت کهنه
|
||||||
|
* جابهجا میشد.
|
||||||
|
*
|
||||||
|
* وقتها با `exclude_appointment_uuid` گرفته میشوند تا خودِ نوبت فعلی جای خالی حساب
|
||||||
|
* شود؛ وگرنه بیمار ساعت خودش را «پر» میبیند.
|
||||||
|
*/
|
||||||
|
function RescheduleModal({ appointment, loading, onDone }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [date, setDate] = useState(null);
|
||||||
|
const [slots, setSlots] = useState([]);
|
||||||
|
const [picked, setPicked] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const days = nextDays();
|
||||||
|
const doctorUuid = appointment?.doctor?.uuid;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !date || !doctorUuid || !appointment?.uuid) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setError(null);
|
||||||
|
setPicked(null);
|
||||||
|
setBusy(true);
|
||||||
|
|
||||||
|
request
|
||||||
|
.getServiceSlotsForReschedule(
|
||||||
|
doctorUuid,
|
||||||
|
date,
|
||||||
|
appointment.uuid,
|
||||||
|
(appointment.service_items ?? []).map((s) => s.uuid).filter(Boolean)
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const groups = adaptServiceSlots(res);
|
||||||
|
setSlots(groups.flatMap((g) => g.slots ?? []));
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) setError(e?.message || "گرفتن وقتهای آزاد ناموفق بود");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setBusy(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, date, doctorUuid, appointment?.uuid]);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setOpen(false);
|
||||||
|
setDate(null);
|
||||||
|
setSlots([]);
|
||||||
|
setPicked(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!picked) return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request.serviceReschedule(appointment.uuid, { start: picked.start });
|
||||||
|
close();
|
||||||
|
onDone?.();
|
||||||
|
} catch (e) {
|
||||||
|
// ۴۰۹ یعنی همین لحظه کس دیگری همان وقت را گرفت — پیام سرور دقیقاً همین را میگوید
|
||||||
|
// و فهرست دوباره خوانده میشود تا بیمار جایگزین ببیند.
|
||||||
|
setError(e?.message || "جابهجایی نوبت ناموفق بود");
|
||||||
|
setDate((d) => d);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
className="!rounded-[4px] !shadow-none !gap-[4px] !py-[6px] md:!py-[7px] lg:!py-[8px] !px-[12px]"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
variant="contained"
|
||||||
|
>
|
||||||
|
جابهجایی نوبت
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal open={open} onClose={close}>
|
||||||
|
<Box
|
||||||
|
sx={styleDefault}
|
||||||
|
className="!w-fit !min-w-[328px] md:!w-fit md:!min-w-[552px] !py-[20px] !px-[24px]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<p className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-medium">
|
||||||
|
جابهجایی نوبت
|
||||||
|
</p>
|
||||||
|
<Button onClick={close} className="!p-1" variant="text">
|
||||||
|
<CloseModalD />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="block h-px w-full bg-[#EFEFEF] my-[12px] md:my-[14px] lg:my-[16px]" />
|
||||||
|
|
||||||
|
<p className="text-[#525252] text-[12px] md:text-[14px] font-normal">
|
||||||
|
روز و ساعت تازه را انتخاب کنید. سرویسهای نوبت و مدت آن تغییری نمیکنند.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-[14px] flex gap-[8px] overflow-x-auto pb-2">
|
||||||
|
{days.map((day) => (
|
||||||
|
<button
|
||||||
|
key={day.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDate(day.value)}
|
||||||
|
className={`shrink-0 rounded-[6px] border px-[12px] py-[6px] text-[12px] ${
|
||||||
|
date === day.value
|
||||||
|
? "border-[#5559CE] bg-[#5559CE]/10 text-[#5559CE]"
|
||||||
|
: "border-[#EFEFEF] text-[#616161]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block">{day.label}</span>
|
||||||
|
<span className="block text-[10px] text-[#9A9A9A]">{day.weekday}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{busy && (
|
||||||
|
<p className="mt-[12px] text-[12px] text-[#7E7E7E]">در حال بررسی وقتهای آزاد…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!busy && date && slots.length === 0 && !error && (
|
||||||
|
<p className="mt-[12px] text-[12px] text-[#7E7E7E]">
|
||||||
|
در این روز وقت آزادی نیست؛ روز دیگری را امتحان کنید.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{slots.length > 0 && (
|
||||||
|
<div className="mt-[12px] flex flex-wrap gap-[8px]">
|
||||||
|
{slots.map((slot) => (
|
||||||
|
<button
|
||||||
|
key={slot.start}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPicked(slot)}
|
||||||
|
className={`rounded-[6px] border px-[12px] py-[6px] text-[13px] ${
|
||||||
|
picked?.start_time === slot.start_time
|
||||||
|
? "border-[#5559CE] bg-[#5559CE]/10 text-[#5559CE]"
|
||||||
|
: "border-[#EFEFEF] text-[#616161]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{slot.start_time}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="mt-[12px] text-[12px] text-[#D64545]">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-[16px] mt-[16px]">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={close}
|
||||||
|
className="!py-[4px] md:!py-[6px] lg:!py-[8px] !px-[8px] md:!px-[12px] lg:!px-[16px] !rounded-[4px] !border-[#828DE0] !text-[#828DE0] !text-[16px] !font-medium"
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={!picked || busy}
|
||||||
|
onClick={submit}
|
||||||
|
className="!py-[4px] md:!py-[6px] lg:!py-[8px] !px-[8px] md:!px-[12px] lg:!px-[16px] !rounded-[4px] !text-[#EFEFEF] !text-[16px] !font-medium"
|
||||||
|
>
|
||||||
|
جابهجا کن
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RescheduleModal;
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
vi.mock('@/services/response', () => ({
|
||||||
|
request: {
|
||||||
|
getServiceSlotsForReschedule: vi.fn(),
|
||||||
|
serviceReschedule: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { request } from '@/services/response';
|
||||||
|
import RescheduleModal from './RescheduleModal';
|
||||||
|
|
||||||
|
const appointment = {
|
||||||
|
uuid: 'a-1',
|
||||||
|
doctor: { uuid: 'd-1' },
|
||||||
|
service_items: [{ uuid: 's-1' }, { uuid: 's-2' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const slots = {
|
||||||
|
data: {
|
||||||
|
total_duration_minutes: 30,
|
||||||
|
start_times: [
|
||||||
|
{ start: 1_900_000_000, end: 1_900_001_800, start_time: '09:00', end_time: '09:30' },
|
||||||
|
{ start: 1_900_003_600, end: 1_900_005_400, start_time: '10:00', end_time: '10:30' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function openAndPickFirstDay(user) {
|
||||||
|
render(<RescheduleModal appointment={appointment} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'جابهجایی نوبت' }));
|
||||||
|
|
||||||
|
const days = screen.getAllByRole('button').filter((b) => /\d|[۰-۹]/.test(b.textContent));
|
||||||
|
await user.click(days[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('مودال جابهجایی نوبت سایت', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
request.getServiceSlotsForReschedule.mockResolvedValue(slots);
|
||||||
|
request.serviceReschedule.mockResolvedValue({ data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⭐ بدون `exclude_appointment_uuid`، بیمار ساعت خودش را «پر» میبیند و نمیتواند
|
||||||
|
* حتی به همان روز جابهجا شود.
|
||||||
|
*/
|
||||||
|
it('وقتها را با کنارگذاشتن نوبت فعلی میگیرد', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await openAndPickFirstDay(user);
|
||||||
|
|
||||||
|
await waitFor(() => expect(request.getServiceSlotsForReschedule).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const [doctorUuid, , appointmentUuid, serviceUuids] =
|
||||||
|
request.getServiceSlotsForReschedule.mock.calls[0];
|
||||||
|
|
||||||
|
expect(doctorUuid).toBe('d-1');
|
||||||
|
expect(appointmentUuid).toBe('a-1');
|
||||||
|
expect(serviceUuids).toEqual(['s-1', 's-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** مدت را سرور حساب میکند؛ فرانت فقط زمان شروع میفرستد. */
|
||||||
|
it('فقط زمان شروع را میفرستد، نه مدت', async () => {
|
||||||
|
const onDone = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<RescheduleModal appointment={appointment} onDone={onDone} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'جابهجایی نوبت' }));
|
||||||
|
|
||||||
|
const days = screen.getAllByRole('button').filter((b) => /\d|[۰-۹]/.test(b.textContent));
|
||||||
|
await user.click(days[0]);
|
||||||
|
|
||||||
|
const slot = await screen.findByRole('button', { name: '09:00' });
|
||||||
|
await user.click(slot);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'جابهجا کن' }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(request.serviceReschedule).toHaveBeenCalledWith('a-1', { start: 1_900_000_000 }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('روزِ بدون وقت آزاد را صریح میگوید', async () => {
|
||||||
|
request.getServiceSlotsForReschedule.mockResolvedValue({ data: { start_times: [] } });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await openAndPickFirstDay(user);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/در این روز وقت آزادی نیست/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** ۴۰۹ یعنی همین لحظه کس دیگری گرفت — پیام سرور باید دیده شود. */
|
||||||
|
it('پیام خطای سرور را نشان میدهد', async () => {
|
||||||
|
request.serviceReschedule.mockRejectedValue(new Error('این بازه زمانی قبلاً رزرو شده است'));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await openAndPickFirstDay(user);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: '09:00' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'جابهجا کن' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('این بازه زمانی قبلاً رزرو شده است')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,66 @@
|
|||||||
import { useState } from "react";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Box, Button, Modal } from "@mui/material";
|
import { Box, Button, Modal } from "@mui/material";
|
||||||
import { styleDefault } from "@/mui";
|
import { styleDefault } from "@/mui";
|
||||||
import CloseModalD from "@/components/icons/CloseModalD";
|
import CloseModalD from "@/components/icons/CloseModalD";
|
||||||
|
import { request } from "@/services/response";
|
||||||
|
import { numberToArStyle } from "@/helper";
|
||||||
|
|
||||||
function ModalDeleteComment({ loading }) {
|
/**
|
||||||
|
* لغو نوبت با نمایش پیامد مالی **پیش از** تأیید.
|
||||||
|
*
|
||||||
|
* پیشنمایش از همان محاسبهای میآید که خودِ لغو انجام میدهد، پس عددی که بیمار میبیند
|
||||||
|
* همان است که کسر میشود. تا پیش از این، این مودال هیچ درخواستی نمیفرستاد و دکمهٔ
|
||||||
|
* «لغو نوبت» فقط پنجره را میبست — یعنی متن تبلیغاتی سایت وعدهای میداد که UI نداشت.
|
||||||
|
*/
|
||||||
|
function ModalDeleteComment({ loading, appointment, onDone }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [preview, setPreview] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
const handleOpen = () => setOpen(true);
|
const handleOpen = () => setOpen(true);
|
||||||
const handleClose = () => setOpen(false);
|
const handleClose = () => {
|
||||||
|
setOpen(false);
|
||||||
|
setPreview(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !appointment?.uuid) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
request
|
||||||
|
.getCancellationPreview(appointment.uuid)
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) setPreview(res?.data ?? res ?? null);
|
||||||
|
})
|
||||||
|
// نبودِ پیشنمایش نباید لغو را قفل کند؛ فقط باید صریح گفته شود.
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setPreview(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, appointment?.uuid]);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request.cancelAppointment(appointment.uuid, { by: "user" });
|
||||||
|
handleClose();
|
||||||
|
onDone?.();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.message || "لغو نوبت ناموفق بود");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -37,6 +91,38 @@ function ModalDeleteComment({ loading }) {
|
|||||||
<p className="text-[#525252] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
|
<p className="text-[#525252] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
|
||||||
آیا از لغو کردن نوبت مطمئن هستید؟
|
آیا از لغو کردن نوبت مطمئن هستید؟
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-[12px] rounded-[6px] bg-[#F7F7F7] p-[12px] text-[12px] md:text-[13px] leading-[2]">
|
||||||
|
{preview === null ? (
|
||||||
|
<span className="text-[#7E7E7E]">
|
||||||
|
پیامد مالی لغو در دسترس نیست؛ لغو انجام میشود ولی مبلغ را از پشتیبانی
|
||||||
|
بپرسید.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[#7E7E7E]">جریمهٔ لغو</span>
|
||||||
|
<strong className={preview.penalty_rials > 0 ? "text-[#D64545]" : "text-[#2E9E63]"}>
|
||||||
|
{preview.penalty_rials > 0
|
||||||
|
? `${numberToArStyle(Math.round(preview.penalty_rials / 10))} تومان`
|
||||||
|
: "بدون جریمه"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{preview.within_free_window && (
|
||||||
|
<div className="text-[#2E9E63]">این لغو در بازهٔ رایگان است.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(preview.notes ?? []).map((note, i) => (
|
||||||
|
<div key={i} className="text-[#9A9A9A]">
|
||||||
|
{note}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="mt-[10px] text-[12px] text-[#D64545]">{error}</p>}
|
||||||
<div className="flex items-center justify-end gap-[16px] mt-[12px]">
|
<div className="flex items-center justify-end gap-[16px] mt-[12px]">
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -47,7 +133,8 @@ function ModalDeleteComment({ loading }) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleClose}
|
disabled={busy}
|
||||||
|
onClick={submit}
|
||||||
className="!py-[4px] md:!py-[6px] lg:!py-[8px] !px-[8px] md:!px-[12px] lg:!px-[16px] !rounded-[4px] !text-[#EFEFEF] !text-[16px] !font-medium"
|
className="!py-[4px] md:!py-[6px] lg:!py-[8px] !px-[8px] md:!px-[12px] lg:!px-[16px] !rounded-[4px] !text-[#EFEFEF] !text-[16px] !font-medium"
|
||||||
>
|
>
|
||||||
لغو نوبت
|
لغو نوبت
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
vi.mock('@/services/response', () => ({
|
||||||
|
request: {
|
||||||
|
getCancellationPreview: vi.fn(),
|
||||||
|
cancelAppointment: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { request } from '@/services/response';
|
||||||
|
import ModalDeleteComment from './index';
|
||||||
|
|
||||||
|
const appointment = { uuid: 'a-1', slot_start: 2_000_000_000, status: 'confirmed' };
|
||||||
|
|
||||||
|
const preview = (over = {}) => ({
|
||||||
|
data: {
|
||||||
|
penalty_rials: 2_500_000,
|
||||||
|
deposit_refundable: true,
|
||||||
|
credit_refundable: true,
|
||||||
|
within_free_window: false,
|
||||||
|
notes: [],
|
||||||
|
paid_rials: 5_000_000,
|
||||||
|
...over,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('مودال لغو نوبت سایت', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
request.cancelAppointment.mockResolvedValue({ data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** ⭐ تا پیش از این، دکمهٔ تأیید فقط پنجره را میبست و هیچ درخواستی نمیرفت. */
|
||||||
|
it('پیش از تأیید، جریمه را از سرور نشان میدهد', async () => {
|
||||||
|
request.getCancellationPreview.mockResolvedValue(preview());
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<ModalDeleteComment appointment={appointment} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
|
||||||
|
|
||||||
|
// ۲٬۵۰۰٬۰۰۰ ریال ⇒ ۲۵۰٬۰۰۰ تومان. قالب رقم به ICU محیط وابسته است، پس فقط
|
||||||
|
// «مبلغ درست + واحد» سنجیده میشود نه شکل رقمها.
|
||||||
|
const amount = await screen.findByText(/تومان$/);
|
||||||
|
expect(amount.textContent.replace(/[^0-9۰-۹٠-٩]/g, '')).toMatch(/2500{2}0|۲۵۰۰۰۰|٢٥٠٠٠٠/);
|
||||||
|
expect(request.cancelAppointment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('در بازهٔ رایگان «بدون جریمه» میگوید، نه صفر', async () => {
|
||||||
|
request.getCancellationPreview.mockResolvedValue(
|
||||||
|
preview({ penalty_rials: 0, within_free_window: true }),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<ModalDeleteComment appointment={appointment} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('بدون جریمه')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('این لغو در بازهٔ رایگان است.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('تأیید، لغو را واقعاً میفرستد و والد را خبر میکند', async () => {
|
||||||
|
request.getCancellationPreview.mockResolvedValue(preview());
|
||||||
|
const onDone = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<ModalDeleteComment appointment={appointment} onDone={onDone} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
|
||||||
|
await screen.findByText(/جریمهٔ لغو/);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'لغو نوبت' }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(request.cancelAppointment).toHaveBeenCalledWith('a-1', { by: 'user' }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** پیشنمایشی که نیامده نباید لغو را قفل کند — ولی باید صریح بگوید نیامده. */
|
||||||
|
it('نبودِ پیشنمایش، لغو را قفل نمیکند', async () => {
|
||||||
|
request.getCancellationPreview.mockRejectedValue(new Error('down'));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<ModalDeleteComment appointment={appointment} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/پیامد مالی لغو در دسترس نیست/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'لغو نوبت' })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,6 @@ import GreenCalendarTurn from "@/components/icons/GreenCalendarTurn";
|
|||||||
import { Button } from "@mui/material";
|
import { Button } from "@mui/material";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import DoctorAvatar from "@/app/component/DoctorAvatar";
|
import DoctorAvatar from "@/app/component/DoctorAvatar";
|
||||||
import SpecialtyChips from "@/app/component/SpecialtyChips";
|
|
||||||
import { bookingState } from "./bookingState";
|
import { bookingState } from "./bookingState";
|
||||||
|
|
||||||
function ItemAppointment({ turn, loading, doctorSlug, booking }) {
|
function ItemAppointment({ turn, loading, doctorSlug, booking }) {
|
||||||
@@ -27,9 +26,13 @@ function ItemAppointment({ turn, loading, doctorSlug, booking }) {
|
|||||||
<p className="text-[#3B3B3B] text-[14px] font-bold">{turn?.name}</p>
|
<p className="text-[#3B3B3B] text-[14px] font-bold">{turn?.name}</p>
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
<TextLoading width={65} height={15} loading={loading}>
|
<TextLoading width={65} height={15} loading={loading}>
|
||||||
{/* ستون نوبتدهی باریک است؛ همان الگوی کارت پزشک — تخصص اصلی و
|
<p className="text-[#525252] text-[14px] font-medium">
|
||||||
شمارندهٔ بقیه — تا شش نام پشتسرهم ارتفاعش را چند برابر نکند. */}
|
تخصص:
|
||||||
<SpecialtyChips specialties={turn?.specialties} />
|
{turn?.specialties?.map(
|
||||||
|
(item, idx) =>
|
||||||
|
`${item.name} ${turn.specialties.length === idx + 1 ? "" : "|"} `
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,20 +22,10 @@ const formatJalali = (ts) => {
|
|||||||
* قابلرزرو را برمیگرداند. تا وقتی حداقل یک محل هست، نوبتدهی فعال است — حتی
|
* قابلرزرو را برمیگرداند. تا وقتی حداقل یک محل هست، نوبتدهی فعال است — حتی
|
||||||
* اگر فیلد قدیمی active به هر دلیل false باشد. بدون محل، همان فیلدهای doctor
|
* اگر فیلد قدیمی active به هر دلیل false باشد. بدون محل، همان فیلدهای doctor
|
||||||
* ملاک میمانند (سازگاری با پزشک بدون برنامه).
|
* ملاک میمانند (سازگاری با پزشک بدون برنامه).
|
||||||
*
|
|
||||||
* منبع مسیر سومی است: دستگاه و اتاق تقویم خودشان را دارند و به برنامهٔ هفتگیِ پزشک
|
|
||||||
* وابسته نیستند. پزشکی که خودش نوبت آنلاین نمیدهد ولی دستگاهش میدهد، «نوبتدهی
|
|
||||||
* غیرفعال» نیست — فقط ویزیت مستقیم ندارد.
|
|
||||||
*/
|
*/
|
||||||
export function bookingState(doctor, bookingLocations, bookingResources = []) {
|
export function bookingState(doctor, bookingLocations) {
|
||||||
const hasLocations =
|
const hasLocations =
|
||||||
Array.isArray(bookingLocations) && bookingLocations.length > 0;
|
Array.isArray(bookingLocations) && bookingLocations.length > 0;
|
||||||
const hasResources =
|
|
||||||
Array.isArray(bookingResources) && bookingResources.length > 0;
|
|
||||||
|
|
||||||
if (!hasLocations && hasResources) {
|
|
||||||
return { enabled: true, label: "رزرو نوبت دستگاه و خدمات" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasLocations) {
|
if (!hasLocations) {
|
||||||
const enabled = doctor?.active !== false && !!doctor?.free_turn;
|
const enabled = doctor?.active !== false && !!doctor?.free_turn;
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { bookingState } from './bookingState';
|
|
||||||
|
|
||||||
const LOCATION = { next_available_at: null };
|
|
||||||
const RESOURCE = { uuid: 'r1', name: 'کندلا2021' };
|
|
||||||
|
|
||||||
describe('bookingState', () => {
|
|
||||||
it('محل دارد → فعال', () => {
|
|
||||||
expect(bookingState({ active: true }, [LOCATION]).enabled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('نه محل و نه منبع و نه free_turn → غیرفعال', () => {
|
|
||||||
const state = bookingState({ active: true, free_turn: null }, [], []);
|
|
||||||
|
|
||||||
expect(state.enabled).toBe(false);
|
|
||||||
expect(state.label).toBe('نوبتدهی غیرفعال است');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('بدون محل ولی با منبع → فعال', () => {
|
|
||||||
const state = bookingState({ active: true, free_turn: null }, [], [RESOURCE]);
|
|
||||||
|
|
||||||
expect(state.enabled).toBe(true);
|
|
||||||
expect(state.label).toBe('رزرو نوبت دستگاه و خدمات');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('پزشکِ غیرفعال ولی دارای منبعِ قابل رزرو → همچنان فعال', () => {
|
|
||||||
// منبع تقویم خودش را دارد؛ پرچم قدیمیِ پزشک آن را خاموش نمیکند.
|
|
||||||
expect(bookingState({ active: false, free_turn: null }, [], [RESOURCE]).enabled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('محل مقدم بر منبع است — برچسبِ زودترین نوبت حفظ میشود', () => {
|
|
||||||
const state = bookingState({ active: true }, [{ next_available_at: null }], [RESOURCE]);
|
|
||||||
|
|
||||||
expect(state.label).toBe('فعلاً نوبت خالی ندارد');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('بدون محل و بدون منبع ولی با free_turn → همان رفتار قبلی', () => {
|
|
||||||
const state = bookingState({ active: true, free_turn: 'فردا ۱۰:۰۰' }, [], []);
|
|
||||||
|
|
||||||
expect(state.enabled).toBe(true);
|
|
||||||
expect(state.label).toBe('اولین نوبت آزاد: فردا ۱۰:۰۰');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,8 +6,8 @@ import ArrowLeftD from "@/components/icons/ArrowLeftD";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { bookingState } from "./bookingState";
|
import { bookingState } from "./bookingState";
|
||||||
|
|
||||||
function AppointmentList({ doctor, loading, doctorSlug, bookingLocations = [], bookingResources = [] }) {
|
function AppointmentList({ doctor, loading, doctorSlug, bookingLocations = [] }) {
|
||||||
const booking = bookingState(doctor, bookingLocations, bookingResources);
|
const booking = bookingState(doctor, bookingLocations);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ul className="hidden sticky top-[122px] sm:top-[150px] mt:top-[178px] lg:top-[206px] lg:flex w-[38%] flex-col justify-start items-center gap-8">
|
<ul className="hidden sticky top-[122px] sm:top-[150px] mt:top-[178px] lg:top-[206px] lg:flex w-[38%] flex-col justify-start items-center gap-8">
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ import jsPDF from "jspdf";
|
|||||||
import { Button } from "@mui/material";
|
import { Button } from "@mui/material";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
const A4_WIDTH_MM = 210;
|
|
||||||
const A4_HEIGHT_MM = 297;
|
|
||||||
|
|
||||||
const VAZIR_WEIGHTS = [
|
const VAZIR_WEIGHTS = [
|
||||||
[400, "Vazirmatn-RD-FD-Regular.ttf"],
|
[400, "Vazirmatn-RD-FD-Regular.ttf"],
|
||||||
[500, "Vazirmatn-RD-FD-Medium.ttf"],
|
[500, "Vazirmatn-RD-FD-Medium.ttf"],
|
||||||
@@ -102,27 +99,17 @@ function List({ hiddenRef, data, qrReady }) {
|
|||||||
pxHeight = canvas.height;
|
pxHeight = canvas.height;
|
||||||
}
|
}
|
||||||
|
|
||||||
// کاغذ همیشه A4 است، نه اندازهای که از نسبت تصویر درمیآید: خروجی قبلی
|
const imgWidth = 210;
|
||||||
// ۲۱۰×۲۶۲٫۵ میلیمتر بود و روی هیچ پرینتری A4 حساب نمیشد.
|
const pxPerMm = pxWidth / imgWidth;
|
||||||
const pdf = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
|
const imgHeight = pxHeight / pxPerMm;
|
||||||
|
|
||||||
// پوستر خودش نسبت A4 دارد، پس عملاً کل صفحه را پر میکند. این محاسبه تور
|
const pdf = new jsPDF({
|
||||||
// ایمنی است تا اگر ابعاد پوستر روزی عوض شد، تصویر از کاغذ بیرون نزند.
|
orientation: "portrait",
|
||||||
let width = A4_WIDTH_MM;
|
unit: "mm",
|
||||||
let height = (pxHeight / pxWidth) * width;
|
format: [imgWidth, imgHeight],
|
||||||
if (height > A4_HEIGHT_MM) {
|
});
|
||||||
height = A4_HEIGHT_MM;
|
|
||||||
width = (pxWidth / pxHeight) * height;
|
|
||||||
}
|
|
||||||
|
|
||||||
pdf.addImage(
|
pdf.addImage(imgData, "PNG", 0, 0, imgWidth, imgHeight);
|
||||||
imgData,
|
|
||||||
"PNG",
|
|
||||||
(A4_WIDTH_MM - width) / 2,
|
|
||||||
(A4_HEIGHT_MM - height) / 2,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
);
|
|
||||||
const safeName = (data?.name ?? "doctor").replace(/[\\/:*?"<>|]+/g, "-");
|
const safeName = (data?.name ?? "doctor").replace(/[\\/:*?"<>|]+/g, "-");
|
||||||
pdf.save(`poster-${safeName}.pdf`);
|
pdf.save(`poster-${safeName}.pdf`);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function Share({ data }) {
|
|||||||
<ShareDI />
|
<ShareDI />
|
||||||
<p className="hidden lg:flex">اشتراک گذاری</p>
|
<p className="hidden lg:flex">اشتراک گذاری</p>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="fixed w-[1080px] h-[1527px] top-0 left-0 opacity-0 pointer-events-none -z-10">
|
<div className="fixed w-[1080px] h-[1350px] top-0 left-0 opacity-0 pointer-events-none -z-10">
|
||||||
<div ref={hiddenRef}>
|
<div ref={hiddenRef}>
|
||||||
{variant === "dark" ? (
|
{variant === "dark" ? (
|
||||||
<Poster data={data} onQrReady={handleQrReady} />
|
<Poster data={data} onQrReady={handleQrReady} />
|
||||||
|
|||||||
@@ -46,20 +46,12 @@ function Title({ doctor }) {
|
|||||||
</div>
|
</div>
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
<TextLoading width={100} height={20}>
|
<TextLoading width={100} height={20}>
|
||||||
{/* اینجا جای فهرست کاملِ تخصصهاست — سربرگ صفحه فقط تخصص اصلی را نشان
|
<h2 className="text-[#525252] text-[14px] md:text-[16px] font-medium">
|
||||||
میدهد. چیپهای شکننده جای رشتهٔ درهم نشستند: پیش از این خروجی
|
تخصص:
|
||||||
«تخصص:جراحی عمومی | … | » بود، بدون فاصله بعد از دونقطه و با
|
{doctor?.specialties?.map(
|
||||||
جداکنندهٔ آویزان در انتها. */}
|
(item, idx) =>
|
||||||
<h2 className="flex flex-wrap items-center gap-[6px] text-[#525252] text-[14px] md:text-[16px] font-medium">
|
`${item.name} ${doctor.specialties.length === idx + 1 ? "" : "|"} `
|
||||||
<span>تخصص:</span>
|
)}
|
||||||
{doctor?.specialties?.map((item) => (
|
|
||||||
<span
|
|
||||||
key={item.id ?? item.name}
|
|
||||||
className="px-[10px] py-[3px] rounded-md bg-[#F8F8FF] text-[13px] md:text-[14px] font-normal"
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</h2>
|
</h2>
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
<div className="flex items-center justify-start gap-11">
|
<div className="flex items-center justify-start gap-11">
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import RoutingC from "@/components/icons/RoutingC";
|
|||||||
// Leaflet به window نیاز دارد → فقط کلاینت
|
// Leaflet به window نیاز دارد → فقط کلاینت
|
||||||
const MapView = dynamic(() => import("@/components/common/MapView"), { ssr: false });
|
const MapView = dynamic(() => import("@/components/common/MapView"), { ssr: false });
|
||||||
|
|
||||||
function Item({ data, bookable = true, defaultOpen = false }) {
|
function Item({ data, bookable = true }) {
|
||||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const handleOpen = () => setOpen(true);
|
const handleOpen = () => setOpen(true);
|
||||||
@@ -50,9 +50,10 @@ function Item({ data, bookable = true, defaultOpen = false }) {
|
|||||||
<p className="text-[#525252] text-[16px] font-normal">
|
<p className="text-[#525252] text-[16px] font-normal">
|
||||||
آدرس: {data.address}
|
آدرس: {data.address}
|
||||||
</p>
|
</p>
|
||||||
{/* شمارهٔ مطب اینجا نمیآید: دادهٔ عمومی نیست و API هم دیگر آن را برنمیگرداند.
|
<div className="flex w-full items-center justify-between">
|
||||||
بیمار شماره را در «جزئیات نوبت شما» — بعد از گرفتن نوبت — میبیند. */}
|
<p className="text-[#525252] text-[16px] font-normal">
|
||||||
<div className="flex w-full items-center justify-end">
|
تلفن: {data.telephone}
|
||||||
|
</p>
|
||||||
{data?.map?.latitude && data?.map?.longitude && (
|
{data?.map?.latitude && data?.map?.longitude && (
|
||||||
<ModalOpenLocation
|
<ModalOpenLocation
|
||||||
open={open}
|
open={open}
|
||||||
|
|||||||
@@ -14,13 +14,7 @@ function Locations({ addresses, bookableAddressUuids = [] }) {
|
|||||||
{addresses.map((item, idx) => (
|
{addresses.map((item, idx) => (
|
||||||
<CustomLoading key={idx} width="full" height={40}>
|
<CustomLoading key={idx} width="full" height={40}>
|
||||||
<li className="w-full">
|
<li className="w-full">
|
||||||
{/* اولین محل از ابتدا باز است: بازدیدکننده معمولاً یک آدرس بیشتر
|
<Item data={item} bookable={bookable.has(item.uuid)} />
|
||||||
نمیخواهد و بستنِ همه یعنی یک کلیک اضافه برای همان یک آدرس. */}
|
|
||||||
<Item
|
|
||||||
data={item}
|
|
||||||
bookable={bookable.has(item.uuid)}
|
|
||||||
defaultOpen={idx === 0}
|
|
||||||
/>
|
|
||||||
{addresses.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>
|
<span className="w-full h-px bg-[#EFEFEF] block my-[12px] md:my-[14px] lg:my-[16px]"></span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import { fireEvent } from "@testing-library/dom";
|
|
||||||
|
|
||||||
// خروجی قبلی کاغذِ [۲۱۰ × ارتفاعِ تصویر] میساخت، یعنی ۲۱۰×۲۶۲٫۵ میلیمتر. روی هیچ
|
|
||||||
// پرینتری A4 حساب نمیشد. این تست همان قرارداد را قفل میکند: یک صفحهٔ A4.
|
|
||||||
const jsPdfCalls = { ctor: [], addImage: [], save: [] };
|
|
||||||
|
|
||||||
vi.mock("jspdf", () => ({
|
|
||||||
default: class {
|
|
||||||
constructor(options) {
|
|
||||||
jsPdfCalls.ctor.push(options);
|
|
||||||
}
|
|
||||||
addImage(...args) {
|
|
||||||
jsPdfCalls.addImage.push(args);
|
|
||||||
}
|
|
||||||
save(name) {
|
|
||||||
jsPdfCalls.save.push(name);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("html-to-image", () => ({
|
|
||||||
toPng: vi.fn(async () => "data:image/png;base64,AAAA"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("html2canvas", () => ({ default: vi.fn() }));
|
|
||||||
|
|
||||||
vi.mock("react-toastify", () => ({
|
|
||||||
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
// فونت وزیر با fetch به data-url تبدیل میشود؛ در jsdom باید تأمین شود وگرنه مسیر
|
|
||||||
// جایگزینِ html2canvas اجرا میشود و تست چیز دیگری را میسنجد.
|
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => ({ blob: async () => new Blob(["x"]) })));
|
|
||||||
|
|
||||||
import List from "./List";
|
|
||||||
|
|
||||||
// پوستر A4 است: ۱۰۸۰×۱۵۲۷ فیزیکی، که با pixelRatio=2 دو برابر میشود.
|
|
||||||
function posterRef({ width = 1080, height = 1527 } = {}) {
|
|
||||||
const element = document.createElement("div");
|
|
||||||
Object.defineProperty(element, "offsetWidth", { value: width });
|
|
||||||
Object.defineProperty(element, "offsetHeight", { value: height });
|
|
||||||
element.querySelectorAll = () => [];
|
|
||||||
return { current: element };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function download(ref) {
|
|
||||||
render(<List hiddenRef={ref} data={{ name: "مرضیه حسینی" }} qrReady />);
|
|
||||||
fireEvent.click(screen.getByLabelText("دانلود"));
|
|
||||||
await waitFor(() => expect(jsPdfCalls.save.length).toBe(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("PDF پوستر", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jsPdfCalls.ctor = [];
|
|
||||||
jsPdfCalls.addImage = [];
|
|
||||||
jsPdfCalls.save = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
it("کاغذ A4 است، نه اندازهای که از نسبت تصویر درمیآید", async () => {
|
|
||||||
await download(posterRef());
|
|
||||||
|
|
||||||
expect(jsPdfCalls.ctor[0]).toMatchObject({
|
|
||||||
orientation: "portrait",
|
|
||||||
unit: "mm",
|
|
||||||
format: "a4",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("پوستر A4 کل صفحه را پر میکند و یک صفحه بیشتر نیست", async () => {
|
|
||||||
await download(posterRef());
|
|
||||||
|
|
||||||
const [, , x, y, width, height] = jsPdfCalls.addImage[0];
|
|
||||||
|
|
||||||
expect(jsPdfCalls.addImage).toHaveLength(1);
|
|
||||||
expect(width).toBeCloseTo(210, 1);
|
|
||||||
expect(height).toBeCloseTo(297, 0);
|
|
||||||
expect(x).toBeCloseTo(0, 1);
|
|
||||||
expect(y).toBeCloseTo(0, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** تور ایمنی: اگر ابعاد پوستر روزی بلندتر شد، تصویر باید کوچک شود نه اینکه از کاغذ بزند بیرون. */
|
|
||||||
it("پوستر بلندتر از A4 داخل کاغذ جا داده میشود، نه بریده", async () => {
|
|
||||||
await download(posterRef({ width: 1080, height: 2000 }));
|
|
||||||
|
|
||||||
const [, , x, y, width, height] = jsPdfCalls.addImage[0];
|
|
||||||
|
|
||||||
expect(height).toBeLessThanOrEqual(297);
|
|
||||||
expect(width).toBeLessThanOrEqual(210);
|
|
||||||
expect(x).toBeGreaterThan(0);
|
|
||||||
expect(y).toBeCloseTo(0, 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import DoctorPage from "@/components/doctor";
|
|
||||||
|
|
||||||
vi.mock("@/components/doctor/appointmentList", () => ({ default: () => null }));
|
|
||||||
vi.mock("@/components/doctor/detailDoctor", () => ({ default: () => null }));
|
|
||||||
vi.mock("@/components/doctor/claim", () => ({ default: () => null }));
|
|
||||||
vi.mock("@/components/doctor/detailDoctor/Share", () => ({ default: () => null }));
|
|
||||||
vi.mock("@/components/specialties/detail/Faq", () => ({ default: () => null }));
|
|
||||||
|
|
||||||
// همان شش تخصص دکتر محمدباقر جهانتاب.
|
|
||||||
const JAHANTAB = {
|
|
||||||
name: "محمدباقر جهانتاب",
|
|
||||||
specialties: [
|
|
||||||
{ id: "13", name: "جراحی عمومی", parent_id: null },
|
|
||||||
{ id: "14", name: "جراحی پلاستیک و زیبایی", parent_id: "13" },
|
|
||||||
{ id: "167", name: "جراحی لاپاراسکوپی", parent_id: "13" },
|
|
||||||
{ id: "168", name: "جراح تیروئید", parent_id: "13" },
|
|
||||||
{ id: "169", name: "جراح گوارش", parent_id: "13" },
|
|
||||||
{ id: "170", name: "جراحی سرطانها", parent_id: "13" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("سربرگ صفحهٔ پزشک", () => {
|
|
||||||
it("فقط تخصص اصلی را نشان میدهد، نه هر شش تا", () => {
|
|
||||||
render(<DoctorPage doctor={JAHANTAB} />);
|
|
||||||
|
|
||||||
expect(screen.getByRole("link", { name: "جراحی عمومی" })).toBeInTheDocument();
|
|
||||||
// بقیه اینجا نمیآیند؛ فهرست کامل در بدنهٔ صفحه است و آنجا میشکند.
|
|
||||||
expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("جراحی سرطانها")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("تخصص اصلی به صفحهٔ فرود همان تخصص لینک میدهد", () => {
|
|
||||||
render(<DoctorPage doctor={JAHANTAB} />);
|
|
||||||
|
|
||||||
expect(screen.getByRole("link", { name: "جراحی عمومی" })).toHaveAttribute(
|
|
||||||
"href",
|
|
||||||
"/specialties/general-surgery"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("نام پزشک با عنوان نمایش داده میشود", () => {
|
|
||||||
render(<DoctorPage doctor={JAHANTAB} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/محمدباقر جهانتاب/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("پزشکِ فقط-زیرتخصص، همان زیرتخصص را در سربرگ میگیرد", () => {
|
|
||||||
render(
|
|
||||||
<DoctorPage
|
|
||||||
doctor={{ ...JAHANTAB, specialties: [{ id: "169", name: "جراح گوارش", parent_id: "13" }] }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole("link", { name: "جراح گوارش" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("پزشک بدون تخصص سربرگ خالی میگیرد و کرش نمیکند", () => {
|
|
||||||
expect(() =>
|
|
||||||
render(<DoctorPage doctor={{ ...JAHANTAB, specialties: [] }} />)
|
|
||||||
).not.toThrow();
|
|
||||||
|
|
||||||
expect(screen.getByText(/محمدباقر جهانتاب/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+14
-24
@@ -6,7 +6,6 @@ import Share from "./detailDoctor/Share";
|
|||||||
import CustomLoading from "@/app/component/loading/Custom";
|
import CustomLoading from "@/app/component/loading/Custom";
|
||||||
import Faq from "@/components/specialties/detail/Faq";
|
import Faq from "@/components/specialties/detail/Faq";
|
||||||
import specialtiesData from "@/data/specialties.json";
|
import specialtiesData from "@/data/specialties.json";
|
||||||
import { splitSpecialties } from "@/lib/specialtyDisplay";
|
|
||||||
import { doctorTitle } from "@/helper";
|
import { doctorTitle } from "@/helper";
|
||||||
|
|
||||||
// مقصد تمیز صفحهٔ فرود تخصص؛ اگر تخصص slug نداشت، لیست پزشکان با کوئری قدیمی.
|
// مقصد تمیز صفحهٔ فرود تخصص؛ اگر تخصص slug نداشت، لیست پزشکان با کوئری قدیمی.
|
||||||
@@ -15,28 +14,24 @@ const specialtyHref = (name) => {
|
|||||||
return slug ? `/specialties/${slug}` : `/doctors?specialty=${encodeURIComponent(name)}`;
|
return slug ? `/specialties/${slug}` : `/doctors?specialty=${encodeURIComponent(name)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddressUuids = [], bookingLocations = [], bookingResources = [], slug, faq = [] }) {
|
function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddressUuids = [], bookingLocations = [], slug, faq = [] }) {
|
||||||
const { primary: primarySpecialty } = splitSpecialties(doctor?.specialties);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-[92px] sm:pt-[120px] mt:pt-[148px] lg:pt-[176px] mt-[px] padding-responsive">
|
<div className="pt-[92px] sm:pt-[120px] mt:pt-[148px] lg:pt-[176px] mt-[px] padding-responsive">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CustomLoading width={180} height={25}>
|
<CustomLoading width={180} height={25}>
|
||||||
<div className="flex items-center justify-start text-[14px] md:text-[16px] font-normal">
|
<div className="flex items-center justify-start text-[14px] md:text-[16px] font-normal">
|
||||||
{/* این خط یک breadcrumb است، نه فهرست تخصص. چاپ هر شش تخصص با
|
<h2 className="text-[#9B9B9B] text-nowrap flex items-center">
|
||||||
text-nowrap در موبایل از عرض صفحه بیرون میزد. فهرست کامل چند خط
|
{doctor?.specialties?.map((item, idx) => (
|
||||||
پایینتر در Title.js میآید و آنجا میشکند. */}
|
<span key={item.id ?? item.name} className="flex items-center">
|
||||||
{primarySpecialty && (
|
<Link href={specialtyHref(item.name)} className="hover:text-[#525252]">
|
||||||
<h2 className="text-[#9B9B9B] text-nowrap flex items-center min-w-0">
|
{item.name}
|
||||||
<Link
|
</Link>
|
||||||
href={specialtyHref(primarySpecialty.name)}
|
<span className="mx-1">
|
||||||
className="hover:text-[#525252] truncate"
|
{doctor.specialties.length === idx + 1 ? ">" : "|"}
|
||||||
>
|
</span>
|
||||||
{primarySpecialty.name}
|
</span>
|
||||||
</Link>
|
))}
|
||||||
<span className="mx-1">{">"}</span>
|
</h2>
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
<p className="text-[#525252] text-nowrap mr-1">{doctorTitle(doctor?.name)}</p>
|
<p className="text-[#525252] text-nowrap mr-1">{doctorTitle(doctor?.name)}</p>
|
||||||
</div>
|
</div>
|
||||||
</CustomLoading>
|
</CustomLoading>
|
||||||
@@ -52,12 +47,7 @@ function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddres
|
|||||||
addresses={addresses}
|
addresses={addresses}
|
||||||
bookableAddressUuids={bookableAddressUuids}
|
bookableAddressUuids={bookableAddressUuids}
|
||||||
/>
|
/>
|
||||||
<AppointmentList
|
<AppointmentList doctor={doctor} doctorSlug={slug} bookingLocations={bookingLocations} />
|
||||||
doctor={doctor}
|
|
||||||
doctorSlug={slug}
|
|
||||||
bookingLocations={bookingLocations}
|
|
||||||
bookingResources={bookingResources}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<ClaimProfileSection doctor={doctor} />
|
<ClaimProfileSection doctor={doctor} />
|
||||||
<Faq items={faq} />
|
<Faq items={faq} />
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import DoctorAvatar from "@/app/component/DoctorAvatar";
|
import DoctorAvatar from "@/app/component/DoctorAvatar";
|
||||||
import QrImg from "./QrImg";
|
import QrImg from "./QrImg";
|
||||||
import SiteLogo from "@/components/common/SiteLogo";
|
import SiteLogo from "./SiteLogo";
|
||||||
import { getStateInfoClient } from "@/lib/getStateInfoClient";
|
import { getStateInfoClient } from "@/lib/getStateInfoClient";
|
||||||
import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay";
|
|
||||||
import { doctorTitle } from "@/helper";
|
import { doctorTitle } from "@/helper";
|
||||||
|
|
||||||
import StarDI from "@/components/icons/StarDI";
|
import StarDI from "@/components/icons/StarDI";
|
||||||
@@ -22,9 +21,7 @@ function PosterLight({ data, onQrReady }) {
|
|||||||
setCity(matchedCity || null);
|
setCity(matchedCity || null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// قرینهٔ پوستر تیره — همان دلیل: برش عددیِ ثابت با نامهای بلند از کادر بیرون میزد.
|
const specialties = (data?.specialties ?? []).filter((s) => s?.name).slice(0, 4);
|
||||||
const { primary, rest } = splitSpecialties(data?.specialties);
|
|
||||||
const subSpecialties = posterSpecialtyLine(rest, 78);
|
|
||||||
const services = (data?.expertise ?? []).filter((s) => s?.name);
|
const services = (data?.expertise ?? []).filter((s) => s?.name);
|
||||||
const shownServices = services.slice(0, 8);
|
const shownServices = services.slice(0, 8);
|
||||||
const moreServices = services.length - shownServices.length;
|
const moreServices = services.length - shownServices.length;
|
||||||
@@ -47,7 +44,7 @@ function PosterLight({ data, onQrReady }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
className="relative w-[1080px] h-[1527px] overflow-hidden flex flex-col bg-[#F6F9FD] text-center"
|
className="relative w-[1080px] h-[1350px] overflow-hidden flex flex-col bg-[#F6F9FD] text-center"
|
||||||
>
|
>
|
||||||
{/* top brand band */}
|
{/* top brand band */}
|
||||||
<div
|
<div
|
||||||
@@ -71,9 +68,7 @@ function PosterLight({ data, onQrReady }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* بالا-چین میماند تا آواتار روی نوار بالایی سوار بماند؛ فاصلهٔ اضافیِ کاغذ A4
|
<div className="flex-1 px-[72px] flex flex-col items-center">
|
||||||
بهصورت فضای خالی بالای فوتر مینشیند، نه بین آواتار و نوار. */}
|
|
||||||
<div className="flex-1 px-[72px] pb-[40px] flex flex-col items-center">
|
|
||||||
{/* avatar overlapping band */}
|
{/* avatar overlapping band */}
|
||||||
<div className="-mt-[8px] pt-[40px]">
|
<div className="-mt-[8px] pt-[40px]">
|
||||||
<div
|
<div
|
||||||
@@ -93,23 +88,18 @@ function PosterLight({ data, onQrReady }) {
|
|||||||
{doctorTitle(data?.name)}
|
{doctorTitle(data?.name)}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{primary && (
|
<div className="w-full flex flex-wrap justify-center gap-[10px] mt-[16px]">
|
||||||
<div className="w-full flex justify-center mt-[16px]">
|
{specialties.map((s, i) => (
|
||||||
<span className="flex items-center shrink-0 rounded-full bg-[#E3EEF9] px-[20px] py-[9px]">
|
<span
|
||||||
|
key={i}
|
||||||
|
className="flex items-center shrink-0 rounded-full bg-[#E3EEF9] px-[20px] py-[9px]"
|
||||||
|
>
|
||||||
<span className="text-[#16528C] text-[23px] font-semibold leading-none whitespace-nowrap">
|
<span className="text-[#16528C] text-[23px] font-semibold leading-none whitespace-nowrap">
|
||||||
{primary.name}
|
{s.name}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* زیرتخصصها یک خط متنی — در چاپ نه کلیک هست نه Popover. */}
|
|
||||||
{subSpecialties.text && (
|
|
||||||
<p className="w-full mt-[12px] max-h-[80px] overflow-hidden text-[#3E6E9E] text-[20px] font-medium leading-[40px]">
|
|
||||||
{subSpecialties.text}
|
|
||||||
{subSpecialties.hidden > 0 && ` و ${subSpecialties.hidden} تخصص دیگر`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{badges.length > 0 && (
|
{badges.length > 0 && (
|
||||||
<div className="w-full flex flex-wrap justify-center items-center gap-[14px] mt-[22px]">
|
<div className="w-full flex flex-wrap justify-center items-center gap-[14px] mt-[22px]">
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import DoctorAvatar from "@/app/component/DoctorAvatar";
|
import DoctorAvatar from "@/app/component/DoctorAvatar";
|
||||||
import QrImg from "./QrImg";
|
import QrImg from "./QrImg";
|
||||||
import SiteLogoBase from "@/components/common/SiteLogo";
|
import SiteLogoBase from "./SiteLogo";
|
||||||
import { getStateInfoClient } from "@/lib/getStateInfoClient";
|
import { getStateInfoClient } from "@/lib/getStateInfoClient";
|
||||||
import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay";
|
|
||||||
import { doctorTitle } from "@/helper";
|
import { doctorTitle } from "@/helper";
|
||||||
|
|
||||||
import StarDI from "@/components/icons/StarDI";
|
import StarDI from "@/components/icons/StarDI";
|
||||||
@@ -42,10 +41,7 @@ function Poster({ data, onQrReady }) {
|
|||||||
setCity(matchedCity || null);
|
setCity(matchedCity || null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// برش بر اساس طول متن است نه عدد ثابت: چهار چیپ با نامهای بلند به سه ردیف
|
const specialties = (data?.specialties ?? []).filter((s) => s?.name).slice(0, 4);
|
||||||
// میرفت، بخش hero را بلند میکرد و بخشهای پایین را از کادر ثابت بیرون میانداخت.
|
|
||||||
const { primary, rest } = splitSpecialties(data?.specialties);
|
|
||||||
const subSpecialties = posterSpecialtyLine(rest, 78);
|
|
||||||
const services = (data?.expertise ?? []).filter((s) => s?.name);
|
const services = (data?.expertise ?? []).filter((s) => s?.name);
|
||||||
const shownServices = services.slice(0, 8);
|
const shownServices = services.slice(0, 8);
|
||||||
const moreServices = services.length - shownServices.length;
|
const moreServices = services.length - shownServices.length;
|
||||||
@@ -84,7 +80,7 @@ function Poster({ data, onQrReady }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
className="relative w-[1080px] h-[1527px] overflow-hidden flex flex-col p-[64px] text-right"
|
className="relative w-[1080px] h-[1350px] overflow-hidden flex flex-col p-[64px] text-right"
|
||||||
style={{ background: POSTER_BG }}
|
style={{ background: POSTER_BG }}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute -top-[180px] -left-[180px] w-[520px] h-[520px] rounded-full bg-[#57A6FF]/20 blur-[80px]" />
|
<div className="pointer-events-none absolute -top-[180px] -left-[180px] w-[520px] h-[520px] rounded-full bg-[#57A6FF]/20 blur-[80px]" />
|
||||||
@@ -132,25 +128,18 @@ function Poster({ data, onQrReady }) {
|
|||||||
<h1 className="text-white text-[46px] font-extrabold leading-tight">
|
<h1 className="text-white text-[46px] font-extrabold leading-tight">
|
||||||
{doctorTitle(data?.name)}
|
{doctorTitle(data?.name)}
|
||||||
</h1>
|
</h1>
|
||||||
{primary && (
|
<div className="flex flex-wrap gap-[10px] mt-[16px]">
|
||||||
<div className="mt-[16px] flex flex-wrap items-center gap-[12px]">
|
{specialties.map((s, i) => (
|
||||||
<span className="flex items-center rounded-full bg-[#F59E0B]/18 border border-[#F59E0B]/40 px-[18px] py-[9px]">
|
<span
|
||||||
|
key={i}
|
||||||
|
className="flex items-center rounded-full bg-[#F59E0B]/18 border border-[#F59E0B]/40 px-[18px] py-[9px]"
|
||||||
|
>
|
||||||
<span className="text-[#FCD9A0] text-[22px] font-semibold leading-none">
|
<span className="text-[#FCD9A0] text-[22px] font-semibold leading-none">
|
||||||
{primary.name}
|
{s.name}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* زیرتخصصها یک خط متنیاند نه چیپ: در چاپ نه کلیک هست نه Popover، پس
|
|
||||||
«+N» بیمعناست و متن خوانا جایش را میگیرد. max-h تور ایمنیِ کادر است
|
|
||||||
اگر بودجهٔ کاراکتر روزی کوتاه تنظیم شود. */}
|
|
||||||
{subSpecialties.text && (
|
|
||||||
<p className="mt-[12px] max-h-[80px] overflow-hidden text-[#C7DBF2] text-[20px] font-medium leading-[40px]">
|
|
||||||
{subSpecialties.text}
|
|
||||||
{subSpecialties.hidden > 0 && ` و ${subSpecialties.hidden} تخصص دیگر`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap gap-[12px] mt-[18px]">
|
<div className="flex flex-wrap gap-[12px] mt-[18px]">
|
||||||
{badges.map((b, i) => (
|
{badges.map((b, i) => (
|
||||||
<span
|
<span
|
||||||
@@ -176,11 +165,9 @@ function Poster({ data, onQrReady }) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* body */}
|
{/* body */}
|
||||||
{/* کارتها به هم چسبیده میمانند و فاصلهٔ اضافیِ کاغذ A4 بالای فوتر مینشیند:
|
<section className="relative mt-[36px] flex-1">
|
||||||
حفرهٔ وسطِ صفحه شبیه محتوای جاافتاده دیده میشد. */}
|
|
||||||
<section className="relative mt-[36px] flex-1 flex flex-col gap-[28px]">
|
|
||||||
{shownServices.length > 0 && (
|
{shownServices.length > 0 && (
|
||||||
<div className="rounded-[28px] bg-white/6 border border-white/10 p-[32px]">
|
<div className="rounded-[28px] bg-white/6 border border-white/10 p-[32px] mb-[28px]">
|
||||||
<SectionTitle icon={<ClipBoard />}>خدمات</SectionTitle>
|
<SectionTitle icon={<ClipBoard />}>خدمات</SectionTitle>
|
||||||
<div className="flex flex-wrap items-center gap-x-[12px] gap-y-[14px]">
|
<div className="flex flex-wrap items-center gap-x-[12px] gap-y-[14px]">
|
||||||
{shownServices.map((s, i) => (
|
{shownServices.map((s, i) => (
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import Poster from "@/components/doctor/poster";
|
|
||||||
import PosterLight from "@/components/doctor/poster/PosterLight";
|
|
||||||
|
|
||||||
vi.mock("@/lib/getStateInfoClient", () => ({
|
|
||||||
getStateInfoClient: () => ({ matchedCity: { site_name: "یاسوج نوبت", domain: "yasuj-nobat.ir" } }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./QrImg", () => ({ default: () => <div data-testid="qr" /> }));
|
|
||||||
vi.mock("@/components/doctor/poster/QrImg", () => ({ default: () => <div data-testid="qr" /> }));
|
|
||||||
|
|
||||||
// همان شش تخصص دکتر محمدباقر جهانتاب، با همان شکلی که API برمیگرداند.
|
|
||||||
const JAHANTAB = {
|
|
||||||
name: "محمدباقر جهانتاب",
|
|
||||||
specialties: [
|
|
||||||
{ id: "13", name: "جراحی عمومی", parent_id: null },
|
|
||||||
{ id: "14", name: "جراحی پلاستیک و زیبایی", parent_id: "13" },
|
|
||||||
{ id: "167", name: "جراحی لاپاراسکوپی", parent_id: "13" },
|
|
||||||
{ id: "168", name: "جراح تیروئید", parent_id: "13" },
|
|
||||||
{ id: "169", name: "جراح گوارش", parent_id: "13" },
|
|
||||||
{ id: "170", name: "جراحی سرطانها", parent_id: "13" },
|
|
||||||
],
|
|
||||||
expertise: [],
|
|
||||||
address: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe.each([
|
|
||||||
["پوستر تیره", Poster],
|
|
||||||
["پوستر روشن", PosterLight],
|
|
||||||
])("%s", (_label, Component) => {
|
|
||||||
it("تخصص والد را بهعنوان تیتر نشان میدهد", () => {
|
|
||||||
render(<Component data={JAHANTAB} />);
|
|
||||||
|
|
||||||
expect(screen.getByText("جراحی عمومی")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("زیرتخصصها یک خط متنیاند، نه چیپ جداگانه", () => {
|
|
||||||
render(<Component data={JAHANTAB} />);
|
|
||||||
|
|
||||||
// اگر چیپ بودند، هر نام یک گرهٔ متنی مستقل داشت.
|
|
||||||
expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/جراح گوارش/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("مازاد را میشمارد و نمیبرد", () => {
|
|
||||||
render(<Component data={JAHANTAB} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/تخصص دیگر/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ۱۰۸۰×۱۵۲۷ همان نسبت A4 است (۲۱۰:۲۹۷). هر عدد دیگری یعنی PDF یا حاشیهٔ سفید
|
|
||||||
// میگیرد یا از کاغذ بیرون میزند.
|
|
||||||
it("کادر ثابت ۱۰۸۰×۱۵۲۷ با نسبت A4 و overflow-hidden دستنخورده است", () => {
|
|
||||||
const { container } = render(<Component data={JAHANTAB} />);
|
|
||||||
const frame = container.firstChild;
|
|
||||||
|
|
||||||
expect(frame.className).toContain("w-[1080px]");
|
|
||||||
expect(frame.className).toContain("h-[1527px]");
|
|
||||||
expect(frame.className).toContain("overflow-hidden");
|
|
||||||
expect(1527 / 1080).toBeCloseTo(297 / 210, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("خط زیرتخصص از بودجهٔ کاراکتر نمیگذرد", () => {
|
|
||||||
// jsdom چیدمان را اندازه نمیگیرد، پس سرریزِ پیکسلی اینجا اثباتشدنی نیست.
|
|
||||||
// چیزی که اثبات میشود همان سازوکاری است که سرریز را میبندد: طول متن کراندار.
|
|
||||||
render(<Component data={JAHANTAB} />);
|
|
||||||
|
|
||||||
const line = screen.getByText(/جراح گوارش/);
|
|
||||||
expect(line.textContent.length).toBeLessThan(120);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("پزشک تکتخصص خط زیرتخصص ندارد", () => {
|
|
||||||
render(
|
|
||||||
<Component
|
|
||||||
data={{ ...JAHANTAB, specialties: [{ id: "13", name: "جراحی عمومی", parent_id: null }] }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("جراحی عمومی")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText(/تخصص دیگر/)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("پزشک بدون تخصص کرش نمیکند", () => {
|
|
||||||
expect(() => render(<Component data={{ ...JAHANTAB, specialties: [] }} />)).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import Title from "@/components/doctor/detailDoctor/Title";
|
|
||||||
import ItemAppointment from "@/components/doctor/appointmentList/ItemAppointment";
|
|
||||||
|
|
||||||
vi.mock("@/components/doctor/detailDoctor/Share", () => ({ default: () => null }));
|
|
||||||
|
|
||||||
// همان شش تخصص دکتر محمدباقر جهانتاب.
|
|
||||||
const SPECIALTIES = [
|
|
||||||
{ id: "13", name: "جراحی عمومی", parent_id: null },
|
|
||||||
{ id: "14", name: "جراحی پلاستیک و زیبایی", parent_id: "13" },
|
|
||||||
{ id: "167", name: "جراحی لاپاراسکوپی", parent_id: "13" },
|
|
||||||
{ id: "168", name: "جراح تیروئید", parent_id: "13" },
|
|
||||||
{ id: "169", name: "جراح گوارش", parent_id: "13" },
|
|
||||||
{ id: "170", name: "جراحی سرطانها", parent_id: "13" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DOCTOR = { name: "محمدباقر جهانتاب", specialties: SPECIALTIES, experience: 5 };
|
|
||||||
|
|
||||||
describe("بدنهٔ پروفایل — فهرست کامل تخصصها", () => {
|
|
||||||
it("هر شش تخصص را جدا نشان میدهد، نه یک رشتهٔ درهم", () => {
|
|
||||||
render(<Title doctor={DOCTOR} />);
|
|
||||||
|
|
||||||
for (const s of SPECIALTIES) {
|
|
||||||
expect(screen.getByText(s.name)).toBeInTheDocument();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("بعد از «تخصص:» نام نمیچسبد", () => {
|
|
||||||
// باگ قبلی: خروجی «تخصص:جراحی عمومی» بود.
|
|
||||||
render(<Title doctor={DOCTOR} />);
|
|
||||||
|
|
||||||
expect(screen.getByText("تخصص:")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText(/تخصص:جراحی/)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("جداکنندهٔ آویزان در انتها ندارد", () => {
|
|
||||||
const { container } = render(<Title doctor={DOCTOR} />);
|
|
||||||
|
|
||||||
expect(container.textContent).not.toMatch(/\|\s*$/);
|
|
||||||
expect(container.textContent).not.toContain("|");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("پزشک بدون تخصص فقط برچسب را نشان میدهد و کرش نمیکند", () => {
|
|
||||||
expect(() =>
|
|
||||||
render(<Title doctor={{ ...DOCTOR, specialties: [] }} />)
|
|
||||||
).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ستون نوبتدهی — تخصص اصلی و شمارنده", () => {
|
|
||||||
const turn = { ...DOCTOR, uuid: "u-1", specialties: SPECIALTIES };
|
|
||||||
|
|
||||||
it("فقط تخصص اصلی و شمارندهٔ بقیه را نشان میدهد", () => {
|
|
||||||
render(<ItemAppointment turn={turn} doctorSlug="u-1" />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: "نمایش 5 تخصص دیگر" })
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("جداکنندهٔ آویزان ندارد", () => {
|
|
||||||
const { container } = render(<ItemAppointment turn={turn} doctorSlug="u-1" />);
|
|
||||||
|
|
||||||
expect(container.textContent).not.toContain("|");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("پزشک بدون تخصص کرش نمیکند", () => {
|
|
||||||
expect(() =>
|
|
||||||
render(<ItemAppointment turn={{ ...turn, specialties: [] }} doctorSlug="u-1" />)
|
|
||||||
).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,20 @@
|
|||||||
import { nextSpecialtyFilter } from "@/helper";
|
import specialties from "@/data/specialties.json";
|
||||||
import Form from "./form";
|
import Form from "./form";
|
||||||
|
|
||||||
function Content({ filter, setFilter, updateData, setDataInURL }) {
|
function Content({ filter, setFilter, updateData, setDataInURL }) {
|
||||||
const changeSpecialty = (name, value) => {
|
const changeSpecialty = (name, value) => {
|
||||||
const newFilter = nextSpecialtyFilter(filter, name, value);
|
const newFilter = { ...filter, [name]: value };
|
||||||
|
|
||||||
|
if (name === "category") {
|
||||||
|
if (value) {
|
||||||
|
const filtered = specialties
|
||||||
|
.filter((item) => item.parent_id)
|
||||||
|
.filter((item) => String(item.parent_id) === String(value.id));
|
||||||
|
newFilter.specialty = filtered[0] || null;
|
||||||
|
} else {
|
||||||
|
newFilter.specialty = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setFilter(newFilter);
|
setFilter(newFilter);
|
||||||
setDataInURL(newFilter);
|
setDataInURL(newFilter);
|
||||||
|
|||||||
@@ -24,13 +24,9 @@ function Form({ filter, changeSpecialty, updateData }) {
|
|||||||
<CateSelector
|
<CateSelector
|
||||||
name="specialty"
|
name="specialty"
|
||||||
list={childrenList}
|
list={childrenList}
|
||||||
updateData={changeSpecialty}
|
updateData={updateData}
|
||||||
value={filter.specialty}
|
value={filter.specialty}
|
||||||
// خالیبودن یعنی کل گروه؛ برچسب همین را میگوید تا کاربر فکر نکند
|
label="تخصص"
|
||||||
// چیزی را جا انداخته.
|
|
||||||
label={
|
|
||||||
filter.category ? `همه تخصصهای ${filter.category.name}` : "تخصص"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-[40px] mb-[32px]">
|
<div className="mt-[40px] mb-[32px]">
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function SearchField({ filter, setFilter, setDataInURL, sendReq }) {
|
|||||||
clearText={clearText}
|
clearText={clearText}
|
||||||
inputValue={filter.name}
|
inputValue={filter.name}
|
||||||
handleSearch={handleSearch}
|
handleSearch={handleSearch}
|
||||||
placeholder="جستجوی نام پزشک یا تخصص ..."
|
placeholder="جستجوی نام پزشک ..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,28 +16,16 @@ import LogoNamad from "./LogoNamad";
|
|||||||
import HeadMenu from "./menu/HeadMenu";
|
import HeadMenu from "./menu/HeadMenu";
|
||||||
import ListMenu from "./menu/ListMenu";
|
import ListMenu from "./menu/ListMenu";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { isRootCity } from "@/lib/rootCity";
|
|
||||||
|
|
||||||
// روی دامنهٔ ریشه (nobat724.com) شهری در کار نیست، پس همان «شهر شما» میماند؛
|
const head = [
|
||||||
// روی دامنهٔ هر شهر، نام همان شهر مینشیند. لینک نسبی است تا هر دامنه به ریشهٔ
|
{ icon: <LocationF />, text: "جستجوی پزشک در شهر شما" },
|
||||||
// خودش برود و کاربر بین دامنهها پرت نشود.
|
{ icon: <CalendarAddF />, text: "نوبت گیری سریع و آسان" },
|
||||||
const buildHead = (matchedCity) => {
|
{
|
||||||
const cityName = isRootCity(matchedCity) ? null : matchedCity?.name;
|
icon: <StethoscopeF />,
|
||||||
|
text: "دشبورد اختصاصی برای پزشکان",
|
||||||
return [
|
link: "https://clinic-pro.ir/",
|
||||||
{
|
},
|
||||||
icon: <LocationF />,
|
];
|
||||||
text: cityName ? `جستجوی پزشک در شهر ${cityName}` : "جستجوی پزشک در شهر شما",
|
|
||||||
link: "/",
|
|
||||||
},
|
|
||||||
{ icon: <CalendarAddF />, text: "نوبت گیری سریع و آسان", link: "/" },
|
|
||||||
{
|
|
||||||
icon: <StethoscopeF />,
|
|
||||||
text: "مدیریت کلینیک با کلینیک پرو",
|
|
||||||
link: "https://clinic-pro.ir/",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildSocialUrl(name, value) {
|
function buildSocialUrl(name, value) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
@@ -82,8 +70,12 @@ function Footer({ matchedCity }) {
|
|||||||
return (
|
return (
|
||||||
<div className="pb-[11px]">
|
<div className="pb-[11px]">
|
||||||
<div className="flex items-center justify-evenly gap-[12px]">
|
<div className="flex items-center justify-evenly gap-[12px]">
|
||||||
{buildHead(matchedCity).map((item, idx) => (
|
{head.map((item, idx) => (
|
||||||
<Link key={idx} href={item.link}>
|
<Link
|
||||||
|
key={idx}
|
||||||
|
href={item.link || ""}
|
||||||
|
onClick={(e) => !item.link && e.preventDefault}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="flex flex-col lg:flex-row items-center cursor-pointer justify-start gap-1 p-[6px] hover:transition-all transition-all duration-300 hover:duration-300 rounded-[8px] border-solid border border-[#EFEFEF] bg-[#F7F7F7] hover:bg-none hover:border-[#FDBD9F]"
|
className="flex flex-col lg:flex-row items-center cursor-pointer justify-start gap-1 p-[6px] hover:transition-all transition-all duration-300 hover:duration-300 rounded-[8px] border-solid border border-[#EFEFEF] bg-[#F7F7F7] hover:bg-none hover:border-[#FDBD9F]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { childSpecialties, parentSpecialty } from "@/lib/specialtyIndexing";
|
|
||||||
|
|
||||||
const CHIP =
|
|
||||||
"px-[14px] py-[8px] rounded-lg bg-[#FFF] border border-[#EFEFEF] text-[14px] font-medium text-[#525252] hover:bg-[#5559CE] hover:text-[#FFF] hover:border-[#5559CE] transition-all duration-500";
|
|
||||||
|
|
||||||
const CHIP_ACTIVE =
|
|
||||||
"px-[14px] py-[8px] rounded-lg bg-[#5559CE] border border-[#5559CE] text-[14px] font-medium text-[#FFF]";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ناوبری والد/فرزندِ صفحهٔ تخصص.
|
|
||||||
*
|
|
||||||
* تا پیش از این، صفحهٔ «جراحی عمومی» هیچ نشانی از زیرشاخههایش نداشت و صفحهٔ
|
|
||||||
* «جراح گوارش» راهی به گروهش. کاربر برای دیدن گروه باید URL دستکاری میکرد.
|
|
||||||
*
|
|
||||||
* روی صفحهٔ والد، «همه تخصصهای X» حالت جاری است نه یک لینک دیگر: بکاند
|
|
||||||
* `specialty_id` را به همهٔ نوادگان گسترش میدهد، پس همین صفحه از قبل همهٔ پزشکان
|
|
||||||
* گروه را نشان میدهد. برچسبِ فعال همین را صریح میکند تا کاربر دنبال گزینه نگردد.
|
|
||||||
*/
|
|
||||||
function SpecialtyTree({ specialty }) {
|
|
||||||
const parent = parentSpecialty(specialty);
|
|
||||||
const siblingsOf = parent ?? specialty;
|
|
||||||
const children = childSpecialties(siblingsOf);
|
|
||||||
|
|
||||||
if (children.length === 0) return null;
|
|
||||||
|
|
||||||
const allHref = `/specialties/${siblingsOf.slug}`;
|
|
||||||
const isAllActive = !parent;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
aria-label={`زیرتخصصهای ${siblingsOf.name}`}
|
|
||||||
className="mt-[20px] flex flex-wrap items-center gap-[8px]"
|
|
||||||
>
|
|
||||||
{isAllActive ? (
|
|
||||||
<span className={CHIP_ACTIVE} aria-current="page">
|
|
||||||
همه تخصصهای {siblingsOf.name}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Link href={allHref} className={CHIP}>
|
|
||||||
همه تخصصهای {siblingsOf.name}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{children.map((child) => {
|
|
||||||
const isActive = String(child.id) === String(specialty.id);
|
|
||||||
|
|
||||||
return isActive ? (
|
|
||||||
<span key={child.id} className={CHIP_ACTIVE} aria-current="page">
|
|
||||||
{child.name}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Link key={child.id} href={`/specialties/${child.slug}`} className={CHIP}>
|
|
||||||
{child.name}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SpecialtyTree;
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import specialtiesData from "@/data/specialties.json";
|
|
||||||
import { childSpecialties, parentSpecialty } from "@/lib/specialtyIndexing";
|
|
||||||
import SpecialtyTree from "@/components/specialties/detail/SpecialtyTree";
|
|
||||||
|
|
||||||
const byName = (name) => specialtiesData.find((s) => s.name === name);
|
|
||||||
|
|
||||||
const GENERAL_SURGERY = byName("جراحی عمومی");
|
|
||||||
const GI = byName("جراح گوارش");
|
|
||||||
|
|
||||||
describe("childSpecialties / parentSpecialty", () => {
|
|
||||||
it("والد فرزندان فعالش را میدهد", () => {
|
|
||||||
const names = childSpecialties(GENERAL_SURGERY).map((s) => s.name);
|
|
||||||
|
|
||||||
expect(names).toContain("جراح گوارش");
|
|
||||||
expect(names).toContain("جراحی پلاستیک و زیبایی");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("برگ فرزندی ندارد", () => {
|
|
||||||
expect(childSpecialties(GI)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("زیرتخصص والدش را میدهد، ریشه null", () => {
|
|
||||||
expect(parentSpecialty(GI)?.id).toBe(GENERAL_SURGERY.id);
|
|
||||||
expect(parentSpecialty(GENERAL_SURGERY)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ورودی تهی کرش نمیکند", () => {
|
|
||||||
expect(childSpecialties(null)).toEqual([]);
|
|
||||||
expect(parentSpecialty(undefined)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ترتیب فرزندان پایدار است", () => {
|
|
||||||
const once = childSpecialties(GENERAL_SURGERY).map((s) => s.id);
|
|
||||||
const twice = childSpecialties(GENERAL_SURGERY).map((s) => s.id);
|
|
||||||
|
|
||||||
expect(once).toEqual(twice);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("SpecialtyTree", () => {
|
|
||||||
it("روی صفحهٔ والد، «همه تخصصهای X» حالت جاری است نه لینک", () => {
|
|
||||||
render(<SpecialtyTree specialty={GENERAL_SURGERY} />);
|
|
||||||
|
|
||||||
const all = screen.getByText(`همه تخصصهای ${GENERAL_SURGERY.name}`);
|
|
||||||
expect(all).toHaveAttribute("aria-current", "page");
|
|
||||||
expect(all.tagName).not.toBe("A");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("روی صفحهٔ والد، همهٔ زیرتخصصها لینک دارند", () => {
|
|
||||||
render(<SpecialtyTree specialty={GENERAL_SURGERY} />);
|
|
||||||
|
|
||||||
const link = screen.getByRole("link", { name: "جراح گوارش" });
|
|
||||||
expect(link).toHaveAttribute("href", `/specialties/${GI.slug}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("روی صفحهٔ زیرتخصص، «همه» لینکِ بازگشت به گروه است", () => {
|
|
||||||
render(<SpecialtyTree specialty={GI} />);
|
|
||||||
|
|
||||||
const back = screen.getByRole("link", {
|
|
||||||
name: `همه تخصصهای ${GENERAL_SURGERY.name}`,
|
|
||||||
});
|
|
||||||
expect(back).toHaveAttribute("href", `/specialties/${GENERAL_SURGERY.slug}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("روی صفحهٔ زیرتخصص، خودش حالت جاری است و خواهرانش لینک", () => {
|
|
||||||
render(<SpecialtyTree specialty={GI} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(GI.name)).toHaveAttribute("aria-current", "page");
|
|
||||||
expect(screen.getByRole("link", { name: "جراح تیروئید" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("تخصص بدون خواهر و فرزند چیزی رندر نمیکند", () => {
|
|
||||||
const lonely = specialtiesData.find(
|
|
||||||
(s) => !s.parent_id && childSpecialties(s).length === 0
|
|
||||||
);
|
|
||||||
const { container } = render(<SpecialtyTree specialty={lonely} />);
|
|
||||||
|
|
||||||
expect(container).toBeEmptyDOMElement();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ناوبری برچسب دسترسیپذیر دارد", () => {
|
|
||||||
render(<SpecialtyTree specialty={GENERAL_SURGERY} />);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByRole("navigation", { name: `زیرتخصصهای ${GENERAL_SURGERY.name}` })
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import specialtiesData from "@/data/specialties.json";
|
|
||||||
import SpecialtyDetailPage from "@/components/specialties/detail";
|
|
||||||
|
|
||||||
const byName = (name) => specialtiesData.find((s) => s.name === name);
|
|
||||||
|
|
||||||
const GENERAL_SURGERY = byName("جراحی عمومی");
|
|
||||||
const GI = byName("جراح گوارش");
|
|
||||||
|
|
||||||
const renderPage = (specialty) =>
|
|
||||||
render(
|
|
||||||
<SpecialtyDetailPage
|
|
||||||
specialty={specialty}
|
|
||||||
specialtyName={specialty.name}
|
|
||||||
cityName="یاسوج"
|
|
||||||
doctors={[]}
|
|
||||||
intro="متن مقدمه"
|
|
||||||
faq={[]}
|
|
||||||
origin="https://yasuj-nobat.ir"
|
|
||||||
slug={specialty.slug}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** حلقههای breadcrumd بهترتیب. */
|
|
||||||
const crumbs = () =>
|
|
||||||
Array.from(
|
|
||||||
screen.getByRole("navigation", { name: "breadcrumb" }).querySelectorAll("li")
|
|
||||||
).map((li) => li.textContent.replace(/>/g, "").trim());
|
|
||||||
|
|
||||||
describe("breadcrumb صفحهٔ تخصص", () => {
|
|
||||||
it("صفحهٔ زیرتخصص حلقهٔ والد را دارد", () => {
|
|
||||||
renderPage(GI);
|
|
||||||
|
|
||||||
expect(crumbs()).toEqual(["خانه", "تخصصها", GENERAL_SURGERY.name, GI.name]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("حلقهٔ والد به صفحهٔ گروه لینک میدهد", () => {
|
|
||||||
renderPage(GI);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByRole("link", { name: GENERAL_SURGERY.name })
|
|
||||||
).toHaveAttribute("href", `/specialties/${GENERAL_SURGERY.slug}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("صفحهٔ ریشه حلقهٔ والد اضافه نمیگیرد", () => {
|
|
||||||
renderPage(GENERAL_SURGERY);
|
|
||||||
|
|
||||||
expect(crumbs()).toEqual(["خانه", "تخصصها", GENERAL_SURGERY.name]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("JSON-LD همان حلقهها را دارد — نما و schema واگرا نمیشوند", () => {
|
|
||||||
const { container } = renderPage(GI);
|
|
||||||
|
|
||||||
const script = container.querySelector('script[type="application/ld+json"]');
|
|
||||||
const jsonLd = JSON.parse(script.innerHTML);
|
|
||||||
|
|
||||||
expect(jsonLd["@type"]).toBe("BreadcrumbList");
|
|
||||||
expect(jsonLd.itemListElement.map((i) => i.name)).toEqual([
|
|
||||||
"خانه",
|
|
||||||
"تخصصها",
|
|
||||||
GENERAL_SURGERY.name,
|
|
||||||
GI.name,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import Pageguide from "@/app/component/Pageguide";
|
import Pageguide from "@/app/component/Pageguide";
|
||||||
import { parentSpecialty } from "@/lib/specialtyIndexing";
|
|
||||||
import DoctorGrid from "./DoctorGrid";
|
import DoctorGrid from "./DoctorGrid";
|
||||||
import Faq from "./Faq";
|
import Faq from "./Faq";
|
||||||
import SpecialtyTree from "./SpecialtyTree";
|
|
||||||
|
|
||||||
// چیدمان عمداً همخانوادهٔ /doctors است: همان padding-responsive، همان فاصلهٔ بالای صفحه،
|
// چیدمان عمداً همخانوادهٔ /doctors است: همان padding-responsive، همان فاصلهٔ بالای صفحه،
|
||||||
// همان تایپوگرافی عنوان و همان گرید کارت پزشک — کاربر نباید حس کند وارد بخش دیگری شده.
|
// همان تایپوگرافی عنوان و همان گرید کارت پزشک — کاربر نباید حس کند وارد بخش دیگری شده.
|
||||||
function SpecialtyDetailPage({ specialty, specialtyName, cityName, doctors, intro, faq, origin, slug }) {
|
function SpecialtyDetailPage({ specialtyName, cityName, doctors, intro, faq, origin, slug }) {
|
||||||
const parent = parentSpecialty(specialty);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-[95px] sm:pt-[120px] padding-responsive">
|
<div className="pt-[95px] sm:pt-[120px] padding-responsive">
|
||||||
<Pageguide
|
<Pageguide
|
||||||
@@ -16,10 +12,6 @@ function SpecialtyDetailPage({ specialty, specialtyName, cityName, doctors, intr
|
|||||||
list={[
|
list={[
|
||||||
{ name: "خانه", href: "/" },
|
{ name: "خانه", href: "/" },
|
||||||
{ name: "تخصصها", href: "/specialties" },
|
{ name: "تخصصها", href: "/specialties" },
|
||||||
// زیرتخصص از دل گروهش میآید؛ بدون این حلقه، مسیر بازگشت به گروه فقط
|
|
||||||
// دستکاری URL بود. Pageguide همین فهرست را به BreadcrumbList هم میدهد،
|
|
||||||
// پس نسخهٔ بصری و schema واگرا نمیشوند.
|
|
||||||
...(parent ? [{ name: parent.name, href: `/specialties/${parent.slug}` }] : []),
|
|
||||||
// href صفحهٔ جاری: بدون آن `item` غایب میشود و اسکیما نامعتبر است
|
// href صفحهٔ جاری: بدون آن `item` غایب میشود و اسکیما نامعتبر است
|
||||||
{ name: specialtyName, href: slug ? `/specialties/${slug}` : "/specialties" },
|
{ name: specialtyName, href: slug ? `/specialties/${slug}` : "/specialties" },
|
||||||
]}
|
]}
|
||||||
@@ -29,8 +21,6 @@ function SpecialtyDetailPage({ specialty, specialtyName, cityName, doctors, intr
|
|||||||
متخصص {specialtyName} در {cityName}
|
متخصص {specialtyName} در {cityName}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<SpecialtyTree specialty={specialty} />
|
|
||||||
|
|
||||||
<p className="text-[#525252] text-[14px] md:text-[16px] font-normal leading-8 mt-[16px] text-justify">
|
<p className="text-[#525252] text-[14px] md:text-[16px] font-normal leading-8 mt-[16px] text-justify">
|
||||||
{intro}
|
{intro}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"alamat-tahooe-mokarrar-dar-bimaran-sartani": "c097ddec",
|
|
||||||
"eyepressure-glasses-specialized-optometry-assessment": "e2cfc318",
|
|
||||||
"warning-signs-sleep-elderly": "6a67ded7",
|
|
||||||
"tashkhis-sari-avarzhe-daru-qatk-masraf": "44424a3c"
|
|
||||||
}
|
|
||||||
@@ -844,50 +844,5 @@
|
|||||||
"status": 1,
|
"status": 1,
|
||||||
"weight": 0,
|
"weight": 0,
|
||||||
"parent_id": 19
|
"parent_id": 19
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 167,
|
|
||||||
"uuid": "86b4cebc-ca17-4c4e-9b1e-c1310ab0521f",
|
|
||||||
"name": "جراحی لاپاراسکوپی",
|
|
||||||
"slug": "جراحی-لاپاراسکوپی",
|
|
||||||
"status": 1,
|
|
||||||
"weight": 0,
|
|
||||||
"parent_id": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 168,
|
|
||||||
"uuid": "6828d049-f0b3-421e-930a-4b6ac57c75f1",
|
|
||||||
"name": "جراح تیروئید",
|
|
||||||
"slug": "جراح-تیروئید",
|
|
||||||
"status": 1,
|
|
||||||
"weight": 0,
|
|
||||||
"parent_id": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 169,
|
|
||||||
"uuid": "fbce44e2-d9b6-412e-a64a-bca4d27a6969",
|
|
||||||
"name": "جراح گوارش",
|
|
||||||
"slug": "جراح-گوارش",
|
|
||||||
"status": 1,
|
|
||||||
"weight": 0,
|
|
||||||
"parent_id": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 170,
|
|
||||||
"uuid": "94fe785b-4886-4710-9619-a7d52050ed40",
|
|
||||||
"name": "جراحی سرطانها",
|
|
||||||
"slug": "جراحی-سرطانها",
|
|
||||||
"status": 1,
|
|
||||||
"weight": 0,
|
|
||||||
"parent_id": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 171,
|
|
||||||
"uuid": "57a5cc39-602e-4e0a-80df-ffd073d8c3d2",
|
|
||||||
"name": "جراحی جنرال",
|
|
||||||
"slug": "جراحی-جنرال",
|
|
||||||
"status": 1,
|
|
||||||
"weight": 0,
|
|
||||||
"parent_id": 13
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
# خطاهای Search Console که باگ نیستند
|
|
||||||
|
|
||||||
هر دامنهٔ شهری در Google Search Console چند دستهٔ «مشکل» نشان میدهد که خروجیِ **تصمیمهای
|
|
||||||
عمدی** سیستماند، نه نقص. این سند مرجع است تا هر بار کسی داشبورد را باز کرد، دوباره دنبال
|
|
||||||
رفع چیزی نگردد که نباید رفع شود.
|
|
||||||
|
|
||||||
سنجشهای این سند روی `behbahan-nobat.ir` و `yasuj-nobat.ir` در ۵ اوت ۲۰۲۶ گرفته شدهاند.
|
|
||||||
|
|
||||||
> آنچه **واقعاً** باگ بود و جدا رفع میشود، اینجا نیست:
|
|
||||||
> `/doctor/undefined` و یکسانبودن صفحهٔ اصلی شهرها.
|
|
||||||
> ← `.claude/prompt/gsc-undefined-link-and-home-uniqueness.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ۱. Excluded by 'noindex' tag
|
|
||||||
|
|
||||||
**نمونه:** ۸۰ صفحه در یاسوج، ۱۲ صفحه در بهبهان — عمدتاً `/doctor/<uuid>`.
|
|
||||||
|
|
||||||
**چرا عمدی است**
|
|
||||||
|
|
||||||
پروفایل پزشکی که از نظام پزشکی وارد شده و خودِ پزشک آن را claim نکرده، محتوای قابل
|
|
||||||
اتکا نیست. `lib/entityQuality.js` همین را اعمال میکند:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export function isUnclaimedDoctor(doctor) {
|
|
||||||
return doctor?.owner_status !== "claimed";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isNoindexDoctor(doctor) {
|
|
||||||
return isThinDoctor(doctor) || isUnclaimedDoctor(doctor);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
همین تابع هم `robots` صفحهٔ پزشک را میسازد و هم ورودی `app/sitemap.js` را فیلتر میکند، تا
|
|
||||||
چیزی که noindex است هرگز در sitemap اعلام نشود.
|
|
||||||
|
|
||||||
پزشک «بیمحتوا» هم noindex میشود: بدون نام واقعی (شمارهٔ تلفن یا «تست» بهجای نام)، یا
|
|
||||||
بدون هیچ تخصص و مکانی.
|
|
||||||
|
|
||||||
**چه چیزی این را برمیدارد**
|
|
||||||
|
|
||||||
فقط claim شدن پروفایل توسط خودِ پزشک، یا کامل شدن نام و تخصص و مکان. **هیچ تغییر کدی
|
|
||||||
لازم نیست و هیچ تغییر کدی هم نباید انجام شود.**
|
|
||||||
|
|
||||||
**چرا `VALIDATE FIX` شکست میخورد**
|
|
||||||
|
|
||||||
در یاسوج این اعتبارسنجی روی ۲۸ ژوئیه شروع و ۵ اوت `Validation failed` شد. طبیعی است:
|
|
||||||
اعتبارسنجی یعنی «به گوگل بگو دیگر noindex نیست» در حالی که صفحات عمداً noindex ماندهاند.
|
|
||||||
**این دکمه را برای این دسته نزنید.** هر بار شکست میخورد و فقط تاریخچهٔ داشبورد را شلوغ میکند.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ۲. Alternate page with proper canonical tag
|
|
||||||
|
|
||||||
**نمونه در بهبهان:** `/about-us`، `/contact-us`، و دو `/clinic/<uuid>`.
|
|
||||||
|
|
||||||
**چرا عمدی است**
|
|
||||||
|
|
||||||
استراتژی canonical سهدسته است و در بالای `lib/getCanonicalUrl.js` مستند شده:
|
|
||||||
|
|
||||||
| دسته | مسیرها | canonical |
|
|
||||||
|---|---|---|
|
|
||||||
| C1-a | `/`، `/doctors`، `/clinics`، `/specialties`، `/blogs` | self روی همان دامنهٔ شهری |
|
|
||||||
| C1-b | `/doctor/[uuid]`، `/clinic/[uuid]`، `/blog/[slug]` | دامنهٔ شهرِ خودِ موجودیت |
|
|
||||||
| C1-c | `/about-us`، `/contact-us`، `/terms`، `/privacy` | دامنهٔ اصلی `nobat724.com` |
|
|
||||||
|
|
||||||
`/about-us` و `/contact-us` روی همهٔ دامنهها محتوای یکسان دارند، پس اعتبارشان عمداً روی
|
|
||||||
دامنهٔ اصلی تجمیع میشود. کلینیکی هم که شهرش بهبهان نیست، canonicalاش به دامنهٔ شهر خودش
|
|
||||||
میرود تا یک موجودیت روی دو دامنه دو صفحهٔ رقیب نسازد.
|
|
||||||
|
|
||||||
`Alternate page with proper canonical tag` در Search Console **گزارش اطلاعاتی** است، نه خطا.
|
|
||||||
عبارت `proper` در عنوانش یعنی گوگل canonical ما را پذیرفته. این دقیقاً نتیجهٔ مطلوب است.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ۳. Page with redirect
|
|
||||||
|
|
||||||
**نمونه:** `http://behbahan-nobat.ir/` — یک صفحه.
|
|
||||||
|
|
||||||
ریدایرکت `http` به `https` است. رفتار درست و لازم. کاری ندارد.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ۴. آدرسهای قدیمی بلاگ که ۴۰۴ میدهند
|
|
||||||
|
|
||||||
**نمونه در بهبهان:** چهار آدرس با slug انگلیسی، مثل
|
|
||||||
`/blog/warning-signs-sleep-elderly` و `/blog/alamat-tahooe-mokarrar-dar-bimaran-sartani`.
|
|
||||||
|
|
||||||
**چه اتفاقی افتاده**
|
|
||||||
|
|
||||||
فرمت slug مقالهها عوض شده. slug امروز فارسی است با پسوند هگز هشتکاراکتری — نمونهٔ
|
|
||||||
واقعی از API:
|
|
||||||
|
|
||||||
```
|
|
||||||
آلرژی-دارویی-تشخیص-علائم-خطرناک-و-ضرورت-مراجعه-به-پزشک-58d2e880
|
|
||||||
```
|
|
||||||
|
|
||||||
slugهای انگلیسیِ قبلی دیگر وجود ندارند و **نگاشت قدیمبهجدید هیچجا ذخیره نشده**.
|
|
||||||
|
|
||||||
**تصمیم: ۴۰۴ پذیرفته میشود**
|
|
||||||
|
|
||||||
۴۰۴ پاسخ صحیح برای محتوایی است که دیگر با آن نشانی وجود ندارد. گوگل خودش این آدرسها را
|
|
||||||
از ایندکس حذف میکند. ساختن redirect نیازمند پیدا کردن دستیِ معادل هر کدام است و فقط
|
|
||||||
چهار آدرس در میان است.
|
|
||||||
|
|
||||||
بررسی شد که خودِ رفتار ۴۰۴ سالم است — سه نشانیِ ناموجود، هر سه `404`:
|
|
||||||
|
|
||||||
```
|
|
||||||
/blog/warning-signs-sleep-elderly → 404
|
|
||||||
/blog/definitely-does-not-exist-xyz-123 → 404
|
|
||||||
/blog/alamat-tahooe-mokarrar-dar-bimaran-sartani → 404
|
|
||||||
```
|
|
||||||
|
|
||||||
**اگر روزی slug دوباره عوض شد**, اینبار قبلش نگاشت را نگه دارید. تکرار همین وضعیت با
|
|
||||||
تعداد بیشتر، دیگر با «۴۰۴ بپذیر» حل نمیشود.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ۵. Discovered — currently not indexed
|
|
||||||
|
|
||||||
**نمونه:** ۷۳ آدرس در یاسوج — صفحات تخصص، کلینیک، پزشک و بلاگ.
|
|
||||||
|
|
||||||
**معنایش**
|
|
||||||
|
|
||||||
گوگل این آدرسها را پیدا کرده اما هنوز crawl نکرده. تقریباً همیشه یعنی سایت در نظر گوگل
|
|
||||||
اولویت crawl پایینی دارد، نه اینکه صفحه خراب باشد.
|
|
||||||
|
|
||||||
**چرا اینجا رخ میدهد**
|
|
||||||
|
|
||||||
دو عامل، و هر دو در حال رفعاند یا عمدیاند:
|
|
||||||
|
|
||||||
۱. سهم بزرگی از صفحات دامنه noindex است (بند ۱). سایتی که بیشتر صفحاتش noindex است،
|
|
||||||
سیگنال ضعیفتری برای صرف بودجهٔ crawl میدهد.
|
|
||||||
۲. صفحهٔ اصلی شهرها تا پیش از این ۹۹٪ شبیه هم بود. این مورد **باگ است** و جدا رفع میشود
|
|
||||||
(پرامپت بالای این سند).
|
|
||||||
|
|
||||||
**کاری که نباید کرد**
|
|
||||||
|
|
||||||
آدرسها را دستی و دستهای برای ایندکس submit نکنید. مشکل کمبودِ کشف نیست؛ اعلام دوبارهٔ
|
|
||||||
همان آدرسها چیزی را جلو نمیاندازد.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## خلاصهٔ تصمیم
|
|
||||||
|
|
||||||
| دستهٔ Search Console | وضعیت | اقدام |
|
|
||||||
|---|---|---|
|
|
||||||
| Excluded by 'noindex' tag | عمدی | هیچ. `Validate fix` نزنید |
|
|
||||||
| Alternate page with proper canonical | عمدی و مطلوب | هیچ |
|
|
||||||
| Page with redirect | طبیعی | هیچ |
|
|
||||||
| Not found (404) — بلاگهای قدیمی | پذیرفتهشده | هیچ |
|
|
||||||
| Not found (404) — `/doctor/undefined` | **باگ** | پرامپت جداگانه |
|
|
||||||
| Duplicate, Google chose different canonical | **باگ محتوایی** | پرامپت جداگانه |
|
|
||||||
| Discovered — currently not indexed | پیامد دو مورد بالا | بعد از رفع آنها دوباره سنجیده شود |
|
|
||||||
@@ -411,25 +411,5 @@
|
|||||||
"409": "Community 409",
|
"409": "Community 409",
|
||||||
"410": "Community 410",
|
"410": "Community 410",
|
||||||
"411": "Community 411",
|
"411": "Community 411",
|
||||||
"412": "Community 412",
|
"412": "Community 412"
|
||||||
"413": "Community 413",
|
|
||||||
"414": "Community 414",
|
|
||||||
"415": "Community 415",
|
|
||||||
"416": "Community 416",
|
|
||||||
"417": "Community 417",
|
|
||||||
"418": "Community 418",
|
|
||||||
"419": "Community 419",
|
|
||||||
"420": "Community 420",
|
|
||||||
"421": "Community 421",
|
|
||||||
"422": "Community 422",
|
|
||||||
"423": "Community 423",
|
|
||||||
"424": "Community 424",
|
|
||||||
"425": "Community 425",
|
|
||||||
"426": "Community 426",
|
|
||||||
"427": "Community 427",
|
|
||||||
"428": "Community 428",
|
|
||||||
"429": "Community 429",
|
|
||||||
"430": "Community 430",
|
|
||||||
"431": "Community 431",
|
|
||||||
"432": "Community 432"
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user