Author SHA1 Message Date
hamed 5e86f115d3 feat: Update poster dimensions to A4 size and enhance PDF generation tests 2026-08-09 13:59:37 +03:30
hamed 54bebbd41a feat: Implement resource-based appointment booking flow
- Updated DoctorPage component to accept bookingResources prop for appointment list.
- Added serviceQuery function to serialize service item UUIDs for API requests.
- Introduced new API endpoints for fetching booking resources and resource slots.
- Enhanced tests for new resource-based booking functionality, including resource selection and service availability.
- Created ResourceSelect component for selecting appointment types, including doctor and resource options.
- Updated appointment submission logic to include resource_uuid in payload when applicable.
- Ensured UI reflects changes in booking flow without disrupting existing doctor-centric experience.
2026-08-09 10:45:52 +03:30
hamed 1668ef8890 Add JSON files for AST cache with empty nodes and edges
- Created new JSON files in the cache for version 0.8.44:
  - a572734068715611824b15faf0381b3e0e139fe7efcf15628b14f29dc027be38.json: Empty nodes and edges with a skipped message for data JSON.
  - c80e24b35764d59b5a43fb04d0f78118d0f2eeb635ea463d9c4904466d3bb29f.json: Empty nodes and edges with a skipped message for non-object root.
  - e4446fb8fcf3fee2bb2562e04e5b6916002646111519f6cd164ea19452bc5f29.json: Populated nodes and edges representing a multi-specialty doctor UI prompt with various relationships and metadata.
2026-08-09 09:14:42 +03:30
hamed 002f388ac8 feat: Enhance metadata generation for About Us and Contact Us pages, implement legacy slug resolution for blog posts, and add city-specific intros 2026-08-09 07:14:15 +03:30
hamed 9cfbd2e002 feat: Revert homepage changes, fix Persian slug 404, and make various article and about us adjustments
- Removed the newly added city-focused section from the homepage.
- Fixed 404 error for Persian slugs in specialties and blog pages by implementing a decode helper.
- Removed the "مخصوص شهر" label from article headers.
- Linked article tags to their respective filter pages.
- Adjusted related content images to prevent distortion.
- Added a section in the "About Us" page for physician registration guidance.
- Updated the logo in the "About Us" header to the correct version.
- Added tests for the new functionality and ensured existing tests are updated accordingly.
2026-08-08 21:06:48 +03:30
hamed e8a073cae6 feat: add SiteLogo component and remove unused logo image 2026-08-08 21:06:14 +03:30
hamed da3f1e5458 feat: remove CityHighlights component and its associated tests 2026-08-08 21:01:52 +03:30
hamed 72beb8b591 feat: Refactor clinic-related functions and tests, removing doctor intro logic 2026-08-08 20:26:17 +03:30
hamed c118791911 feat: remove unused intro prop and related rendering in DoctorsPage component 2026-08-08 20:06:57 +03:30
hamed b494473e40 feat: رفع لینک /doctor/undefined و متمایزسازی صفحهٔ اصلی شهرها
- ایجاد پرامپت برای رفع خطای 404 ناشی از لینک `/doctor/undefined` در سایت‌های بهبهان و یاسوج.
- افزودن کامپوننت `CityHighlights` برای نمایش محتوای یکتا و آمار پزشکان و تخصص‌های پرمراجعه در صفحهٔ اصلی هر شهر.
- به‌روزرسانی تست‌های مربوط به کامپوننت‌های `ItemDoctor` و `CityHighlights` برای اطمینان از عدم وجود لینک‌های نامعتبر و نمایش صحیح اطلاعات.
- ایجاد سند جدید برای مستندسازی خطاهای Search Console که باگ نیستند و نیاز به رفع ندارند.
- افزودن تست‌های واحد برای توابع `buildCityIntro` و دیگر توابع مرتبط با تولید متن یکتا برای صفحات لیست.
2026-08-08 19:14:50 +03:30
hamed 79747c443c feat: Enhance specialty handling and navigation across doctor and specialty pages 2026-08-08 17:33:13 +03:30
hamed 56e264e44c feat: Refactor specialty display logic and enhance specialty filtering
- Introduced SpecialtyChips component to manage the display of doctor's specialties with a primary specialty and a count of additional specialties.
- Updated ItemDoctor component to utilize SpecialtyChips for better UI presentation.
- Enhanced PosterLight and Poster components to display primary specialties and a text line for sub-specialties.
- Implemented nextSpecialtyFilter function to improve specialty selection logic in the Content component.
- Updated search functionality to allow searching by both doctor name and specialty.
- Added new specialties to specialties.json for better coverage.
- Created sync-specialties script to synchronize specialties data with the backend during build.
- Added tests for new components and helper functions to ensure functionality and reliability.
2026-08-08 17:19:22 +03:30
hamedandClaude Opus 5 94c22de8dd docs(prompt): plan the multi-specialty UI work
Companion to clinicpro's doctor-multi-specialty-search prompt, which is already
merged and now returns specialties[].parent_id plus descendant-aware
specialty_id filtering.

Records what the code inspection turned up, so the implementation does not
rediscover it: data/specialties.json is a stale snapshot missing the five newest
children of جراحی عمومی, which is why /specialties/جراح-گوارش 404s and why the
parent/child UI cannot be built until it is synced at build time; the filters
modal force-selects the first child when a group is picked, silently narrowing
the search; and both poster variants slice specialties to a blind four, which
overflows the fixed 1080x1350 frame.

Not executed yet — committed so the plan is not carried around untracked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 16:49:43 +03:30
99 changed files with 8637 additions and 1084 deletions
+422
View File
@@ -0,0 +1,422 @@
# نمایش چندتخصصی پزشک در کارت، فیلترها و پوستر
## پروژه
`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 اضافه کن و گزارش بده.
@@ -0,0 +1,250 @@
# رفع لینک `/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` سه خطا در فایل‌های
بی‌ربط. این‌ها رگرسیون نیستند و درست کردنشان در محدودهٔ این تسک نیست.
@@ -0,0 +1,469 @@
# بازگرداندن صفحهٔ اصلی، رفع ۴۰۴ اسلاگ فارسی، سه اصلاح صفحهٔ مقاله، و دو اصلاح «درباره ما»
## پروژه
`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"`
می‌دهد. صفحات رندر می‌شوند و برای این تست‌ها کافی است، ولی اگر رفتار عجیبی دیدید
اول همین را در نظر بگیرید.
@@ -0,0 +1,303 @@
# رزرو آنلاین منبع‌محور در جریان نوبت‌گیری
## پروژه
`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`، این سه فراخوانی را دستی بررسی کن.
+12 -3
View File
@@ -3,10 +3,19 @@ import Layout from "@/components/layout/StLayout";
import { getStateInfo } from "@/lib/getStateInfo";
export async function generateMetadata() {
const { matchedCity } = await getStateInfo();
const { matchedCity, matchedState, isRoot } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724";
const title = `درباره ما | ${siteName}`;
const description = `آشنایی با ${siteName}، سیستم آنلاین نوبت‌دهی پزشکی. هدف ما ارائه خدمات سریع و کارآمد رزرو نوبت پزشکی برای همه مردم است.`;
const cityName = isRoot ? null : matchedCity?.name;
const provinceName = isRoot ? null : matchedState?.name;
// صفحه self-canonical است؛ عنوان و توضیح باید شهری باشند وگرنه ۳۵ دامنه یک
// متادیتا اعلام می‌کنند و همان duplicate با شکل تازه برمی‌گردد.
const title = cityName
? `درباره ما | نوبت‌دهی آنلاین پزشکان ${cityName} | ${siteName}`
: `درباره ما | ${siteName}`;
const description = cityName
? `آشنایی با ${siteName}، سامانه رزرو آنلاین نوبت پزشکان ${cityName}${provinceName ? ` و ${provinceName}` : ""}. جست‌وجوی پزشک بر اساس تخصص و ثبت نوبت بدون تماس تلفنی.`
: `آشنایی با ${siteName}، سیستم آنلاین نوبت‌دهی پزشکی. هدف ما ارائه خدمات سریع و کارآمد رزرو نوبت پزشکی برای همه مردم است.`;
const image = "/assets/images/og-image.png";
return {
title,
+40 -9
View File
@@ -1,7 +1,7 @@
import { cache } from "react";
import BlogPage from "@/components/blog";
import Layout from "@/components/layout/StLayout";
import { notFound } from "next/navigation";
import { notFound, permanentRedirect } from "next/navigation";
import { fetchReq } from "@/lib/req";
import { getStateInfo } from "@/lib/getStateInfo";
import { getBlogCanonicalOrigin } from "@/lib/getCanonicalUrl";
@@ -11,7 +11,9 @@ import {
findCityById,
} from "@/lib/domainHelpers";
import { safeJsonLd } from "@/lib/sanitize";
import { decodeRouteParam } from "@/lib/routeParams";
import { normalizeBlog, blogCover } from "@/helper";
import legacySlugs from "@/data/blogLegacySlugs.json";
const API_URL = process.env.NEXT_PUBLIC_API_URL;
@@ -38,12 +40,31 @@ async function getScopedBlog(slug) {
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 }) {
const { slug } = await params;
const { slug: rawSlug } = await params;
const slug = decodeRouteParam(rawSlug);
const { matchedCity } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724";
const blog = await getScopedBlog(slug);
const blog = await resolveScopedBlog(slug);
if (!blog) notFound();
try {
@@ -82,7 +103,9 @@ export async function generateMetadata({ params }) {
// دامنه سرو می‌شد و هرکدام خودش را canonical اعلام می‌کرد.
// فیلد canonical_url اختصاصی (مثلاً نسخهٔ شهریِ پایپ‌لاین) مقدم است.
const origin = getBlogCanonicalOrigin(extractEntityCityId(blog));
const canonical = blog.canonical_url || `${origin}/blog/${slug}`;
// slug اینجا decode شده است؛ canonical باید شکل درصدی را بدهد وگرنه روی
// اسلاگ فارسی یک URL با کاراکتر غیر ASCII منتشر می‌شود.
const canonical = blog.canonical_url || `${origin}/blog/${encodeURIComponent(slug)}`;
return {
title,
@@ -115,11 +138,19 @@ export async function generateMetadata({ params }) {
}
async function Blog({ params }) {
const { slug } = await params;
const { slug: rawSlug } = await params;
const slug = decodeRouteParam(rawSlug);
const slugPath = encodeURIComponent(slug);
const blog = normalizeBlog(await getScopedBlog(slug));
const blog = normalizeBlog(await resolveScopedBlog(slug));
if (!blog) notFound();
// اسلاگ کهنه (عنوان عوض شده یا نسل لاتین) به آدرس قطعی منتقل می‌شود؛ دو URL برای
// یک محتوا باقی نمی‌ماند و لینک‌های قدیمیِ ایندکس‌شده اعتبارشان را می‌برند.
if (blog.slug && blog.slug !== slug) {
permanentRedirect(`/blog/${encodeURIComponent(blog.slug)}`);
}
const blogCityId = extractEntityCityId(blog);
const blogCity = findCityById(blogCityId);
const origin = getBlogCanonicalOrigin(blogCityId);
@@ -165,7 +196,7 @@ async function Blog({ params }) {
headline: blog.title,
...(articleDescription && { description: articleDescription }),
image: blogCover(blog),
mainEntityOfPage: { "@type": "WebPage", "@id": `${origin}/blog/${slug}` },
mainEntityOfPage: { "@type": "WebPage", "@id": `${origin}/blog/${slugPath}` },
...(blog.created && {
datePublished: new Date(blog.created * 1000).toISOString(),
}),
@@ -216,7 +247,7 @@ async function Blog({ params }) {
: []),
// آیتم آخر بدون `item` کل BreadcrumbList را نامعتبر می‌کند
{ "@type": "ListItem", position: categoryName ? 4 : 3, name: blog.title,
item: `${origin}/blog/${slug}` },
item: `${origin}/blog/${slugPath}` },
],
}
: null;
@@ -242,7 +273,7 @@ async function Blog({ params }) {
/>
)}
<Layout>
<BlogPage blog={blog} blogs={relatedBlogs} cityName={blogCity?.name} />
<BlogPage blog={blog} blogs={relatedBlogs} />
</Layout>
</>
);
+7
View File
@@ -6,6 +6,8 @@ import { getStateInfo } from "@/lib/getStateInfo";
import { buildClinicParams } from "@/helper";
import { listingRobots } from "@/lib/listingRobots";
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
import { buildClinicsIntro } from "@/lib/listingIntro";
import { resolveCityDisplayName } from "@/lib/domainHelpers";
export async function generateMetadata({ searchParams }) {
const awaitedParams = await searchParams;
@@ -79,6 +81,11 @@ export default async function Clinics({ searchParams }) {
matchedCity={matchedCity}
matchedState={matchedState}
origin={origin}
// قرینهٔ /doctors — buildClinicsIntro هم نوشته و تست شده بود ولی رندر نمی‌شد.
intro={buildClinicsIntro(
resolveCityDisplayName(isRoot ? null : matchedCity),
clinics?.meta?.totalRecords
)}
/>
</Layout>
);
+32 -29
View File
@@ -5,6 +5,7 @@ import CustomLoading from "./loading/Custom";
import TextLoading from "./loading/Text";
import CircularLoading from "./loading/Circular";
import DoctorAvatar from "./DoctorAvatar";
import SpecialtyChips from "./SpecialtyChips";
// Icons
import ArrowLeftD from "@/components/icons/ArrowLeftD";
@@ -13,6 +14,12 @@ import LikeD from "@/components/icons/LikeD";
import PointD from "@/components/icons/PointD";
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 = () => {
setDoctors((prevDoctors) =>
prevDoctors.map((item) =>
@@ -37,20 +44,16 @@ function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
</CircularLoading>
<div className="flex flex-col items-start justify-center gap-2">
<TextLoading loading={loading} width={90} height={15}>
<Link href={`/doctor/${doctor?.uuid}`}>
<p className="text-[#3B3B3B] text-[14px] font-bold">
{doctor?.display_name || doctor?.name}
</p>
</Link>
{href ? (
<Link href={href}>
<p className="text-[#3B3B3B] text-[14px] font-bold">{label}</p>
</Link>
) : (
<p className="text-[#3B3B3B] text-[14px] font-bold">{label}</p>
)}
</TextLoading>
<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>
<SpecialtyChips specialties={doctor?.specialties} />
</TextLoading>
<CustomLoading loading={loading} width={100} height={25}>
<div className="flex rounded items-center justify-start gap-2">
@@ -89,23 +92,23 @@ function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
: "نوبت‌دهی غیرفعال است"}
</p>
</CustomLoading>
{/* Arrow */}
<Link
href={`/doctor/${doctor?.uuid}`}
aria-label={`مشاهده پروفایل ${doctor?.display_name || doctor?.name || "پزشک"}`}
>
<Button
variant="contained"
onClick={handleLoading}
disabled={doctor?.loading}
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>
</Button>
</Link>
{/* Arrow — روی کارتِ بدون uuid (اسکلت بارگذاری) اصلاً رندر نمی‌شود؛ دکمه‌ای که
به جایی نمی‌رود، هم بی‌معناست هم لینکِ شکسته در HTML می‌گذارد. */}
{href && (
<Link href={href} aria-label={`مشاهده پروفایل ${label || "پزشک"}`}>
<Button
variant="contained"
onClick={handleLoading}
disabled={doctor?.loading}
aria-label={`مشاهده پروفایل ${label || "پزشک"}`}
className="!p-[14px] !absolute !left-4 !bottom-4 !rounded-full !w-fit"
>
<div className={doctor && doctor.loading ? "opacity-0" : ""}>
<ArrowLeftD />
</div>
</Button>
</Link>
)}
</li>
);
}
+74
View File
@@ -0,0 +1,74 @@
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);
});
});
+81
View File
@@ -0,0 +1,81 @@
"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;
+74
View File
@@ -0,0 +1,74 @@
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 تخصص دیگر");
});
});
+16 -6
View File
@@ -21,7 +21,7 @@ function gregorianMonthsOf(jalaliMonth) {
});
}
function DatePicker({ setDate, clinicUuid = null }) {
function DatePicker({ setDate, clinicUuid = null, resourceUuid = null, selectedServiceUuids = [] }) {
const params = useParams();
const doctorUuid = params?.doctorId;
@@ -36,15 +36,20 @@ function DatePicker({ setDate, clinicUuid = null }) {
// هر محل، روزهای غیرفعال و وضعیت نوبت‌دهی آنلاین خودش را دارد؛ کش ماه‌ها باید
// با تعویض محل دور ریخته شود وگرنه تقویم محل قبلی باقی می‌ماند.
//
// منبع هم همین‌طور: تقویم هر دستگاه مالِ خودش است، و مدتِ سرویس‌های انتخاب‌شده
// تعیین می‌کند کدام روز جا دارد — پس تغییر هرکدام یعنی کشِ باطل.
const serviceKey = selectedServiceUuids.join(",");
useEffect(() => {
loadedMonths.current = new Set();
setDisabledSet(new Set());
setOnlineEnabled(true);
setAutoSelected(false);
}, [clinicUuid]);
}, [clinicUuid, resourceUuid, serviceKey]);
useEffect(() => {
if (!doctorUuid) return;
if (!doctorUuid && !resourceUuid) return;
const targets = [
...gregorianMonthsOf(baseMonth),
@@ -55,8 +60,13 @@ function DatePicker({ setDate, clinicUuid = null }) {
targets.forEach(({ year, month }) => {
loadedMonths.current.add(`${year}-${month}`);
request
.getMonthAvailability(doctorUuid, year, month, clinicUuid)
// منبع پرچمِ `online_booking_enabled` ندارد — آن روی برنامهٔ هفتگیِ پزشک است.
// نبودِ فیلد یعنی «روشن»، و شرطِ پایین‌تر فقط با `false` صریح واکنش نشان می‌دهد.
const req = resourceUuid
? request.getResourceMonthAvailability(resourceUuid, year, month, selectedServiceUuids)
: request.getMonthAvailability(doctorUuid, year, month, clinicUuid);
req
.then((res) => {
const data = res?.data ?? {};
if (data.online_booking_enabled === false) setOnlineEnabled(false);
@@ -73,7 +83,7 @@ function DatePicker({ setDate, clinicUuid = null }) {
loadedMonths.current.delete(`${year}-${month}`);
});
});
}, [doctorUuid, baseMonth, clinicUuid]);
}, [doctorUuid, baseMonth, clinicUuid, resourceUuid, serviceKey]);
const isDisabled = (date) => {
const day = moment(date).startOf("day");
+11 -4
View File
@@ -17,6 +17,7 @@ function DateTime({
serviceMode = false,
selectedServiceUuids = [],
clinicUuid = null,
resourceUuid = null,
selectedLocation = null,
}) {
const [value, setValue] = useState(0);
@@ -48,11 +49,17 @@ function DateTime({
setHour();
const dateStr = moment.unix(date).format("YYYY-MM-DD");
const req = serviceMode
// منبع تقویم خودش را دارد، پس زمان‌هایش از اندپوینت خودش می‌آید نه از برنامهٔ
// هفتگی پزشک. شکل پاسخ همان `start_times` است، پس adapter مشترک می‌ماند.
const req = resourceUuid
? request
.getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid)
.getResourceSlots(resourceUuid, dateStr, selectedServiceUuids)
.then(adaptServiceSlots)
: request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots);
: serviceMode
? request
.getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid)
.then(adaptServiceSlots)
: request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots);
req
.then((parsed) => {
@@ -69,7 +76,7 @@ function DateTime({
setAppo("در حال حاضر، نوبتی برای این روز موجود نمی‌باشد.");
setValue(0);
});
}, [date, doctor?.uuid, serviceMode, selectedServiceUuids, clinicUuid]);
}, [date, doctor?.uuid, serviceMode, selectedServiceUuids, clinicUuid, resourceUuid]);
return (
<div className="w-full">
+85
View File
@@ -0,0 +1,85 @@
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();
});
});
+25
View File
@@ -1,5 +1,30 @@
import ContactUsPage from "@/components/contactUs";
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() {
return (
+35 -4
View File
@@ -7,6 +7,7 @@ import { getEntityOrigin } from "@/lib/getCanonicalUrl";
import { extractEntityCityId, findCityById } from "@/lib/domainHelpers";
import { buildDoctorFaq } from "@/lib/specialtyContent";
import { isNoindexDoctor } from "@/lib/entityQuality";
import { splitSpecialties } from "@/lib/specialtyDisplay";
import specialtiesData from "@/data/specialties.json";
import { safeJsonLd } from "@/lib/sanitize";
import { imageUrl, doctorTitle } from "@/helper";
@@ -64,6 +65,28 @@ 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 }) {
const { slug } = await params;
const { matchedCity } = await getStateInfo();
@@ -143,9 +166,13 @@ async function Doctor({ params }) {
} catch (error) {}
}
const [addresses, bookingLocations] = doctor
? await Promise.all([getDoctorAddresses(doctor.id), getBookingLocations(doctor.uuid)])
: [[], []];
const [addresses, bookingLocations, bookingResources] = doctor
? await Promise.all([
getDoctorAddresses(doctor.id),
getBookingLocations(doctor.uuid),
getBookingResources(doctor.uuid),
])
: [[], [], []];
// آدرسی که در هیچ برنامهٔ نوبت‌دهی استفاده نشده، محل مراجعه نیست. در کارت
// «موقعیت مکانی» می‌ماند (اطلاعات واقعی مطب است) ولی به JSON-LD نمی‌رود، چون
@@ -246,7 +273,10 @@ async function Doctor({ params }) {
}
: null;
const specialtyLabel = doctor?.specialties?.[0]?.name || "پزشکان";
// همان تخصصی که سربرگ صفحه نشان می‌دهد؛ اگر اینجا اولین آیتم آرایه می‌ماند،
// BreadcrumbList و نمای صفحه دو چیز متفاوت می‌گفتند.
const specialtyLabel =
splitSpecialties(doctor?.specialties).primary?.name || "پزشکان";
// مقصد تمیز صفحهٔ فرود تخصص؛ اگر تخصص slug نداشت، به لیست پزشکان برمی‌گردیم.
const specialtySlug = specialtiesData.find(
(item) => item.name === specialtyLabel && item.status === 1
@@ -315,6 +345,7 @@ async function Doctor({ params }) {
addresses={addresses}
bookableAddressUuids={[...bookableAddressUuids]}
bookingLocations={bookingLocations}
bookingResources={bookingResources}
slug={slug}
faq={faq}
/>
+6 -2
View File
@@ -10,6 +10,7 @@ import { buildSpecialtyFaq, buildSpecialtyIntro } from "@/lib/specialtyContent";
import { isCategorySpecialty } from "@/lib/specialtyIndexing";
import { safeJsonLd } from "@/lib/sanitize";
import { fetchReq } from "@/lib/req";
import { decodeRouteParam } from "@/lib/routeParams";
import { isNextRedirectError } from "@/lib/maintenance";
const API_URL = process.env.NEXT_PUBLIC_API_URL;
@@ -42,7 +43,8 @@ const getDoctors = cache(async (specialtyId, cityId) => {
});
export async function generateMetadata({ params }) {
const { slug } = await params;
const { slug: rawSlug } = await params;
const slug = decodeRouteParam(rawSlug);
const specialty = findSpecialty(slug);
if (!specialty) notFound();
@@ -73,7 +75,8 @@ export async function generateMetadata({ params }) {
}
async function SpecialtyDetail({ params }) {
const { slug } = await params;
const { slug: rawSlug } = await params;
const slug = decodeRouteParam(rawSlug);
const specialty = findSpecialty(slug);
if (!specialty) notFound();
@@ -135,6 +138,7 @@ async function SpecialtyDetail({ params }) {
/>
<Layout name="/specialties">
<SpecialtyDetailPage
specialty={specialty}
specialtyName={specialty.name}
cityName={cityName}
doctors={doctors}
+4 -8
View File
@@ -1,15 +1,11 @@
import Image from "next/image";
import React from "react";
import SiteLogo from "@/components/common/SiteLogo";
function Head({ siteName, slogan }) {
function Head({ siteName, slogan, city }) {
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]">
<Image
src="/assets/images/logo-2.png"
height={46}
width={46}
alt="logo"
/>
{/* رنگ پیش‌فرض لوگو #5559CE است و روی همین پس‌زمینه گم می‌شود؛ نسخهٔ روشن. */}
<SiteLogo city={city} stroke="#FAFAFA" className="w-[46px] h-[46px]" />
<h1 className="text-[#FAFAFA] text-[16px] sm:text-[18px] md:text-[22px] lg:text-[24px] font-bold">
درباره {siteName}
</h1>
+46
View File
@@ -0,0 +1,46 @@
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();
});
});
+25 -2
View File
@@ -1,20 +1,27 @@
import { getStateInfo } from "@/lib/getStateInfo";
import { buildAboutIntro } from "@/lib/staticPageIntro";
import Head from "./Head";
import Services from "./Services";
async function AboutUsPage() {
const { matchedCity } = await getStateInfo();
const { matchedCity, matchedState, isRoot } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت ۷۲۴";
const slogan = matchedCity?.slogan;
// این صفحه self-canonical است؛ بدون متن شهری، ۳۵ دامنه یک محتوا اعلام می‌کردند.
// دامنهٔ ریشه شهر نیست، پس متن شهری نمی‌گیرد.
const cityIntro = isRoot
? ""
: buildAboutIntro(matchedCity?.name, matchedState?.name, siteName, slogan);
return (
<div
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} />
<Head siteName={siteName} slogan={slogan} city={matchedCity} />
<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] "
>
{cityIntro && <p>{cityIntro}</p>}
<p>
{siteName} یک سامانهی آنلاین نوبتدهی پزشکی است که با هدف سادهتر کردن
دسترسی مردم به خدمات سلامت طراحی شده است. ما تلاش میکنیم تجربهی رزرو
@@ -27,6 +34,22 @@ async function AboutUsPage() {
نوبت خود را ثبت کنید. هدف ما کاهش زمان انتظار و ایجاد ارتباط مؤثر میان
بیماران، پزشکان و کلینیکهاست.
</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>
<Services />
</div>
+31 -2
View File
@@ -1,5 +1,6 @@
import Date from "./date";
import ServiceSelect from "./service";
import ResourceSelect from "./resource";
import LocationSelect from "./location/LocationSelect";
// Components
@@ -53,13 +54,29 @@ function Container({
reopenLocationChoice,
onDateChange,
selectedClosedOnDate,
// نوبت‌دهی منبع‌محور (دستگاه / اتاق / یونیت)
resources = [],
selectedResource = null,
resourceStepNeeded = false,
changeResource,
reopenResourceChoice,
}) {
const serviceMode = bookingMode === "service";
const clinicUuid = selectedLocation?.clinic_uuid ?? null;
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;
if (bookingLocations.length === 0) {
if (noBookingAtAll) {
dateStep = (
<p className="w-full max-w-[520px] mx-auto py-[40px] text-center text-[14px] text-[#7A7A7A]">
نوبتدهی آنلاین برای این پزشک فعال نیست.
@@ -73,6 +90,15 @@ function Container({
onSelect={changeLocation}
/>
);
} else if (resourceStepNeeded) {
dateStep = (
<ResourceSelect
resources={resources}
selected={selectedResource}
onSelect={changeResource}
allowDoctorOption={bookingLocations.length > 0}
/>
);
} else if (serviceMode && selectedServiceUuids.length === 0) {
dateStep = (
<ServiceSelect
@@ -93,7 +119,9 @@ function Container({
onChangeService={() => setSelectedServiceUuids([])}
selectedLocation={selectedLocation}
clinicUuid={clinicUuid}
resourceUuid={resourceUuid}
onChangeLocation={multiLocation ? reopenLocationChoice : null}
onChangeResource={resources.length > 0 ? reopenResourceChoice : null}
onDateChange={onDateChange}
closedOnDate={selectedClosedOnDate}
/>
@@ -134,6 +162,7 @@ function Container({
setAppointmentExpiresAt={setAppointmentExpiresAt}
selectedServiceUuids={selectedServiceUuids}
clinicUuid={clinicUuid}
resourceUuid={resourceUuid}
/>,
<Paying
setStep={setStep}
+142
View File
@@ -0,0 +1,142 @@
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,7 +1,15 @@
import DatePicker from "@/app/component/date/datePicker";
function SelectDatePicker({ setDate, disabledDates, clinicUuid }) {
return <DatePicker setDate={setDate} disabledDates={disabledDates} clinicUuid={clinicUuid} />;
function SelectDatePicker({ setDate, disabledDates, clinicUuid, resourceUuid = null, selectedServiceUuids = [] }) {
return (
<DatePicker
setDate={setDate}
disabledDates={disabledDates}
clinicUuid={clinicUuid}
resourceUuid={resourceUuid}
selectedServiceUuids={selectedServiceUuids}
/>
);
}
export default SelectDatePicker;
+8 -2
View File
@@ -1,12 +1,18 @@
import SelectDatePicker from "./SelectDatePicker";
function Time({ isStep, setDate, disabledDates, clinicUuid }) {
function Time({ isStep, setDate, disabledDates, clinicUuid, resourceUuid = null, selectedServiceUuids = [] }) {
return (
<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">
1. انتخاب روز
</p>
<SelectDatePicker setDate={setDate} disabledDates={disabledDates} clinicUuid={clinicUuid} />
<SelectDatePicker
setDate={setDate}
disabledDates={disabledDates}
clinicUuid={clinicUuid}
resourceUuid={resourceUuid}
selectedServiceUuids={selectedServiceUuids}
/>
{!isStep && (
<div className="absolute left-0 top-0 w-full h-full bg-[rgba(255,255,255,0.84)]"></div>
)}
+2 -1
View File
@@ -1,6 +1,6 @@
import DateTime from "@/app/component/date/dateTime";
function Hour({ doctor, setStep, date, isStep, setSelectedSlot, setSelectedDate, serviceMode = false, selectedServiceUuids = [], clinicUuid = null, selectedLocation = null }) {
function Hour({ doctor, setStep, date, isStep, setSelectedSlot, setSelectedDate, serviceMode = false, selectedServiceUuids = [], clinicUuid = null, resourceUuid = null, selectedLocation = null }) {
return (
<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">
@@ -15,6 +15,7 @@ function Hour({ doctor, setStep, date, isStep, setSelectedSlot, setSelectedDate,
serviceMode={serviceMode}
selectedServiceUuids={selectedServiceUuids}
clinicUuid={clinicUuid}
resourceUuid={resourceUuid}
selectedLocation={selectedLocation}
/>
{!isStep && (
+15 -1
View File
@@ -13,7 +13,9 @@ function Date({
onChangeService,
selectedLocation = null,
clinicUuid = null,
resourceUuid = null,
onChangeLocation,
onChangeResource,
onDateChange,
closedOnDate = false,
}) {
@@ -29,7 +31,7 @@ function Date({
<div
className="p-0 lg:p-[24px] rounded-[8px] border border-solid border-transparent lg:border-[#EFEFEF] bg-transparent lg:bg-[#FFF]"
>
{(onChangeLocation || (serviceMode && onChangeService)) && (
{(onChangeLocation || onChangeResource || (serviceMode && onChangeService)) && (
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1">
{onChangeLocation && (
<button
@@ -40,6 +42,15 @@ function Date({
تغییر محل{selectedLocation?.title ? ` (${selectedLocation.title})` : ""}
</button>
)}
{onChangeResource && (
<button
type="button"
onClick={onChangeResource}
className="text-[13px] text-[#5559CE] hover:underline"
>
تغییر نوع نوبت
</button>
)}
{serviceMode && onChangeService && (
<button
type="button"
@@ -72,6 +83,8 @@ function Date({
isStep
disabledDates={disabledDates}
clinicUuid={clinicUuid}
resourceUuid={resourceUuid}
selectedServiceUuids={selectedServiceUuids}
/>
<Hour
isStep
@@ -83,6 +96,7 @@ function Date({
serviceMode={serviceMode}
selectedServiceUuids={selectedServiceUuids}
clinicUuid={clinicUuid}
resourceUuid={resourceUuid}
selectedLocation={selectedLocation}
/>
</div>
+4 -1
View File
@@ -7,7 +7,7 @@ import Cookies from "js-cookie";
import { setAccessToken } from "@/lib/tokenStore";
import { useProvince } from "@/context/ProvinceProvider";
function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt, selectedServiceUuids = [], clinicUuid = null }) {
function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt, selectedServiceUuids = [], clinicUuid = null, resourceUuid = null }) {
const [loading, setLoading] = useState(false);
const { cityId } = useProvince();
const newStep = () => setStep((prev) => prev + 1);
@@ -150,6 +150,9 @@ function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother,
city_id: cityId ?? null,
// حالت نوبت‌دهی سرویسی: backend مدت و slot_end را از این سرویس‌ها بازمحاسبه می‌کند.
...(selectedServiceUuids?.length ? { service_item_uuids: selectedServiceUuids } : {}),
// نوبت منبع‌محور: مدت از زنجیرهٔ حلِ همین منبع بازمحاسبه می‌شود، پس
// slot_end فرستاده‌شده فقط پیشنهاد است.
...(resourceUuid ? { resource_uuid: resourceUuid } : {}),
...(isForAnother
? {
patient_name: [data?.name?.value, data?.family?.value].filter(Boolean).join(" ").trim(),
@@ -0,0 +1,70 @@
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');
});
});
+2
View File
@@ -22,6 +22,7 @@ function Detail({
setAppointmentExpiresAt,
selectedServiceUuids = [],
clinicUuid = null,
resourceUuid = null,
}) {
const [insurance, setInsurance] = useState();
const [supplementaryInsurance, setSupplementaryInsurance] = useState();
@@ -124,6 +125,7 @@ function Detail({
setAppointmentExpiresAt={setAppointmentExpiresAt}
selectedServiceUuids={selectedServiceUuids}
clinicUuid={clinicUuid}
resourceUuid={resourceUuid}
/>
</div>
</div>
+73 -2
View File
@@ -65,6 +65,12 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
// Service-based booking (روش نوبت‌دهی سرویسی)
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 [selectedLocation, setSelectedLocation] = useState(null);
@@ -120,21 +126,81 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
.catch(() => {});
}, [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 هستند: یک پزشک می‌تواند در مطب شخصی
// اسلاتی و در کلینیک سرویسی باشد.
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) => {
setSelectedLocation(location);
setLocationConfirmed(true);
setSelectedResource(null);
setResourceConfirmed(false);
setSelectedServiceUuids([]);
setSelectedSlot(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);
@@ -257,6 +323,11 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
locationConfirmed={locationConfirmed}
changeLocation={changeLocation}
reopenLocationChoice={reopenLocationChoice}
resources={locationResources}
selectedResource={selectedResource}
resourceStepNeeded={resourceStepNeeded}
changeResource={changeResource}
reopenResourceChoice={reopenResourceChoice}
onDateChange={setBrowsingDate}
selectedClosedOnDate={selectedClosedOnDate}
/>
@@ -0,0 +1,64 @@
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();
});
});
+84
View File
@@ -0,0 +1,84 @@
"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;
+55
View File
@@ -0,0 +1,55 @@
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();
});
});
+10 -19
View File
@@ -4,7 +4,7 @@ import { convertTimestampToJalali } from "@/helper";
const crumbClass =
"text-[#9B9B9B] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal";
function Head({ data, cityName }) {
function Head({ data }) {
const firstTag = data?.tag?.[0];
const createdDate = data?.created ? convertTimestampToJalali(data.created) : "";
// normalizeBlog تگ‌ها را به [{name}] تبدیل می‌کند؛ نامِ خالی حذف می‌شود.
@@ -44,18 +44,6 @@ function Head({ data, cityName }) {
</h1>
</div>
<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 && (
<div className="flex items-center justify-start gap-1">
<p className="text-[#9B9B9B] text-[11px] md:text-[13px] lg:text-[14px] font-normal">
@@ -88,15 +76,18 @@ function Head({ data, cityName }) {
)}
</div>
{/* فهرست کامل تگ‌ها — تا این تغییر فقط تگ اول در بردکرامب دیده می‌شد.
صفحهٔ فیلتر تگ وجود ندارد، پس چیپ‌ها بدون لینک رندر می‌شوند. */}
مقصد هر چیپ همان لیست مقاله‌هاست که `?tag=` را می‌خواند؛ شکل شیئیِ href
تگِ حاوی فاصله یا & را هم درست encode می‌کند. */}
{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 key={`${tag}-${idx}`}>
<Link
href={{ pathname: "/blogs", query: { tag } }}
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}
</Link>
</li>
))}
</ul>
+2 -2
View File
@@ -2,10 +2,10 @@ import Detail from "./detail";
import Head from "./head";
import RelatedContent from "./relatedContent";
function BlogPage({ blog, blogs, cityName }) {
function BlogPage({ blog, blogs }) {
return (
<div className="padding-responsive pt-[84px] sm:pt-[110px] md:pt-[140px] lg:pt-[168px]">
<Head data={blog} cityName={cityName} />
<Head data={blog} />
<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} />
<RelatedContent data={blogs} />
+3 -1
View File
@@ -10,8 +10,10 @@ function Item({ data, idx, loading }) {
return (
<li className="flex w-full relative items-start justify-start gap-[8px] py-[12px]">
{/* CustomLoading ظرف نمی‌سازد، پس فرزند مستقیمِ فلکس همین لینک است؛
بدون shrink-0 تیتر بلند عرض عکس را می‌بلعد و ارتفاعش ثابت می‌ماند. */}
<CustomLoading width={80} height={72} loading={loading}>
<Link href={`/blog/${data.uuid}`}>
<Link href={`/blog/${data.uuid}`} className="block shrink-0">
<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}
@@ -0,0 +1,26 @@
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");
});
});
+6 -1
View File
@@ -14,7 +14,7 @@ import cities from "@/data/city.json";
import { isRootCity } from "@/lib/rootCity";
import { resolveCityDisplayName } from "@/lib/domainHelpers";
function ClinicsPage({ clinics, matchedCity, matchedState, origin }) {
function ClinicsPage({ clinics, matchedCity, matchedState, origin, intro }) {
const searchParams = useSearchParams();
const [page, setPage] = useState(clinics?.meta?.currentPage || 1);
const [filteredClinics, setFilteredClinics] = useState(clinics?.data);
@@ -49,6 +49,11 @@ function ClinicsPage({ clinics, matchedCity, matchedState, origin }) {
setFilteredClinics={setFilteredClinics}
/>
</HeadPageList>
{intro && (
<p className="padding-responsive text-[#525252] text-[14px] md:text-[16px] font-normal leading-8 mt-[24px] text-justify">
{intro}
</p>
)}
<List
limit={12}
page={page}
+6 -1
View File
@@ -1,6 +1,6 @@
import Links from "./Links";
function Call({ matchedCity, isDefault }) {
function Call({ matchedCity, isDefault, cityIntro }) {
return (
<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"}`}
@@ -9,6 +9,11 @@ function Call({ matchedCity, isDefault }) {
<h2 className="text-[#FAFAFA] leading-[26px] text-[14px] md:text-[16px] font-normal mt-6">
{matchedCity?.footer_description || "برای ارتباط با ما از راه های زیر استفاده کنید"}
</h2>
{cityIntro && (
<p className="text-[#E5E5F5] leading-[26px] text-[13px] md:text-[15px] font-normal mt-4">
{cityIntro}
</p>
)}
<Links matchedCity={matchedCity} />
</div>
);
+13 -2
View File
@@ -1,13 +1,24 @@
import { getStateInfo } from "@/lib/getStateInfo";
import { buildContactIntro } from "@/lib/staticPageIntro";
import Call from "./call/Call";
import Connect from "./Connect";
async function ContactUsPage() {
const { matchedCity, isRoot } = await getStateInfo();
const { matchedCity, matchedState, isRoot } = await getStateInfo();
// مثل «درباره ما»: این صفحه هم self-canonical شد و بدون متن شهری، تلفن و ایمیل
// یکسانِ ۳۵ دامنه دوباره duplicate می‌ساخت.
const cityIntro = isRoot
? ""
: buildContactIntro(
matchedCity?.name,
matchedState?.name,
matchedCity?.site_name,
matchedCity?.slogan
);
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">
<Call matchedCity={matchedCity} isDefault={isRoot} />
<Call matchedCity={matchedCity} isDefault={isRoot} cityIntro={cityIntro} />
{isRoot && <Connect />}
</div>
);
@@ -5,6 +5,7 @@ import GreenCalendarTurn from "@/components/icons/GreenCalendarTurn";
import { Button } from "@mui/material";
import Link from "next/link";
import DoctorAvatar from "@/app/component/DoctorAvatar";
import SpecialtyChips from "@/app/component/SpecialtyChips";
import { bookingState } from "./bookingState";
function ItemAppointment({ turn, loading, doctorSlug, booking }) {
@@ -26,13 +27,9 @@ function ItemAppointment({ turn, loading, doctorSlug, booking }) {
<p className="text-[#3B3B3B] text-[14px] font-bold">{turn?.name}</p>
</TextLoading>
<TextLoading width={65} height={15} loading={loading}>
<p className="text-[#525252] text-[14px] font-medium">
تخصص:
{turn?.specialties?.map(
(item, idx) =>
`${item.name} ${turn.specialties.length === idx + 1 ? "" : "|"} `
)}
</p>
{/* ستون نوبت‌دهی باریک است؛ همان الگوی کارت پزشک — تخصص اصلی و
شمارندهٔ بقیه — تا شش نام پشت‌سرهم ارتفاعش را چند برابر نکند. */}
<SpecialtyChips specialties={turn?.specialties} />
</TextLoading>
</div>
</div>
@@ -22,10 +22,20 @@ const formatJalali = (ts) => {
* قابل‌رزرو را برمی‌گرداند. تا وقتی حداقل یک محل هست، نوبت‌دهی فعال است — حتی
* اگر فیلد قدیمی active به هر دلیل false باشد. بدون محل، همان فیلدهای doctor
* ملاک می‌مانند (سازگاری با پزشک بدون برنامه).
*
* منبع مسیر سومی است: دستگاه و اتاق تقویم خودشان را دارند و به برنامهٔ هفتگیِ پزشک
* وابسته نیستند. پزشکی که خودش نوبت آنلاین نمی‌دهد ولی دستگاهش می‌دهد، «نوبت‌دهی
* غیرفعال» نیست — فقط ویزیت مستقیم ندارد.
*/
export function bookingState(doctor, bookingLocations) {
export function bookingState(doctor, bookingLocations, bookingResources = []) {
const hasLocations =
Array.isArray(bookingLocations) && bookingLocations.length > 0;
const hasResources =
Array.isArray(bookingResources) && bookingResources.length > 0;
if (!hasLocations && hasResources) {
return { enabled: true, label: "رزرو نوبت دستگاه و خدمات" };
}
if (!hasLocations) {
const enabled = doctor?.active !== false && !!doctor?.free_turn;
@@ -0,0 +1,43 @@
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('اولین نوبت آزاد: فردا ۱۰:۰۰');
});
});
+2 -2
View File
@@ -6,8 +6,8 @@ import ArrowLeftD from "@/components/icons/ArrowLeftD";
import Link from "next/link";
import { bookingState } from "./bookingState";
function AppointmentList({ doctor, loading, doctorSlug, bookingLocations = [] }) {
const booking = bookingState(doctor, bookingLocations);
function AppointmentList({ doctor, loading, doctorSlug, bookingLocations = [], bookingResources = [] }) {
const booking = bookingState(doctor, bookingLocations, bookingResources);
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">
+22 -9
View File
@@ -15,6 +15,9 @@ import jsPDF from "jspdf";
import { Button } from "@mui/material";
import { toast } from "react-toastify";
const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297;
const VAZIR_WEIGHTS = [
[400, "Vazirmatn-RD-FD-Regular.ttf"],
[500, "Vazirmatn-RD-FD-Medium.ttf"],
@@ -99,17 +102,27 @@ function List({ hiddenRef, data, qrReady }) {
pxHeight = canvas.height;
}
const imgWidth = 210;
const pxPerMm = pxWidth / imgWidth;
const imgHeight = pxHeight / pxPerMm;
// کاغذ همیشه A4 است، نه اندازه‌ای که از نسبت تصویر درمی‌آید: خروجی قبلی
// ۲۱۰×۲۶۲٫۵ میلی‌متر بود و روی هیچ پرینتری A4 حساب نمی‌شد.
const pdf = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
const pdf = new jsPDF({
orientation: "portrait",
unit: "mm",
format: [imgWidth, imgHeight],
});
// پوستر خودش نسبت A4 دارد، پس عملاً کل صفحه را پر می‌کند. این محاسبه تور
// ایمنی است تا اگر ابعاد پوستر روزی عوض شد، تصویر از کاغذ بیرون نزند.
let width = A4_WIDTH_MM;
let height = (pxHeight / pxWidth) * width;
if (height > A4_HEIGHT_MM) {
height = A4_HEIGHT_MM;
width = (pxWidth / pxHeight) * height;
}
pdf.addImage(imgData, "PNG", 0, 0, imgWidth, imgHeight);
pdf.addImage(
imgData,
"PNG",
(A4_WIDTH_MM - width) / 2,
(A4_HEIGHT_MM - height) / 2,
width,
height,
);
const safeName = (data?.name ?? "doctor").replace(/[\\/:*?"<>|]+/g, "-");
pdf.save(`poster-${safeName}.pdf`);
} catch {
+1 -1
View File
@@ -41,7 +41,7 @@ function Share({ data }) {
<ShareDI />
<p className="hidden lg:flex">اشتراک گذاری</p>
</Button>
<div className="fixed w-[1080px] h-[1350px] top-0 left-0 opacity-0 pointer-events-none -z-10">
<div className="fixed w-[1080px] h-[1527px] top-0 left-0 opacity-0 pointer-events-none -z-10">
<div ref={hiddenRef}>
{variant === "dark" ? (
<Poster data={data} onQrReady={handleQrReady} />
+14 -6
View File
@@ -46,12 +46,20 @@ function Title({ doctor }) {
</div>
</TextLoading>
<TextLoading width={100} height={20}>
<h2 className="text-[#525252] text-[14px] md:text-[16px] font-medium">
تخصص:
{doctor?.specialties?.map(
(item, idx) =>
`${item.name} ${doctor.specialties.length === idx + 1 ? "" : "|"} `
)}
{/* اینجا جای فهرست کاملِ تخصص‌هاست — سربرگ صفحه فقط تخصص اصلی را نشان
می‌دهد. چیپ‌های شکننده جای رشتهٔ درهم نشستند: پیش از این خروجی
«تخصص:جراحی عمومی | … | » بود، بدون فاصله بعد از دونقطه و با
جداکنندهٔ آویزان در انتها. */}
<h2 className="flex flex-wrap items-center gap-[6px] text-[#525252] text-[14px] md:text-[16px] font-medium">
<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>
</TextLoading>
<div className="flex items-center justify-start gap-11">
@@ -0,0 +1,94 @@
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);
});
});
+66
View File
@@ -0,0 +1,66 @@
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();
});
});
+24 -14
View File
@@ -6,6 +6,7 @@ import Share from "./detailDoctor/Share";
import CustomLoading from "@/app/component/loading/Custom";
import Faq from "@/components/specialties/detail/Faq";
import specialtiesData from "@/data/specialties.json";
import { splitSpecialties } from "@/lib/specialtyDisplay";
import { doctorTitle } from "@/helper";
// مقصد تمیز صفحهٔ فرود تخصص؛ اگر تخصص slug نداشت، لیست پزشکان با کوئری قدیمی.
@@ -14,24 +15,28 @@ const specialtyHref = (name) => {
return slug ? `/specialties/${slug}` : `/doctors?specialty=${encodeURIComponent(name)}`;
};
function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddressUuids = [], bookingLocations = [], slug, faq = [] }) {
function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddressUuids = [], bookingLocations = [], bookingResources = [], slug, faq = [] }) {
const { primary: primarySpecialty } = splitSpecialties(doctor?.specialties);
return (
<div className="pt-[92px] sm:pt-[120px] mt:pt-[148px] lg:pt-[176px] mt-[px] padding-responsive">
<div className="flex items-center justify-between">
<CustomLoading width={180} height={25}>
<div className="flex items-center justify-start text-[14px] md:text-[16px] font-normal">
<h2 className="text-[#9B9B9B] text-nowrap flex items-center">
{doctor?.specialties?.map((item, idx) => (
<span key={item.id ?? item.name} className="flex items-center">
<Link href={specialtyHref(item.name)} className="hover:text-[#525252]">
{item.name}
</Link>
<span className="mx-1">
{doctor.specialties.length === idx + 1 ? ">" : "|"}
</span>
</span>
))}
</h2>
{/* این خط یک breadcrumb است، نه فهرست تخصص. چاپ هر شش تخصص با
text-nowrap در موبایل از عرض صفحه بیرون می‌زد. فهرست کامل چند خط
پایین‌تر در Title.js می‌آید و آنجا می‌شکند. */}
{primarySpecialty && (
<h2 className="text-[#9B9B9B] text-nowrap flex items-center min-w-0">
<Link
href={specialtyHref(primarySpecialty.name)}
className="hover:text-[#525252] truncate"
>
{primarySpecialty.name}
</Link>
<span className="mx-1">{">"}</span>
</h2>
)}
<p className="text-[#525252] text-nowrap mr-1">{doctorTitle(doctor?.name)}</p>
</div>
</CustomLoading>
@@ -47,7 +52,12 @@ function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddres
addresses={addresses}
bookableAddressUuids={bookableAddressUuids}
/>
<AppointmentList doctor={doctor} doctorSlug={slug} bookingLocations={bookingLocations} />
<AppointmentList
doctor={doctor}
doctorSlug={slug}
bookingLocations={bookingLocations}
bookingResources={bookingResources}
/>
</div>
<ClaimProfileSection doctor={doctor} />
<Faq items={faq} />
+23 -13
View File
@@ -2,8 +2,9 @@
import { useEffect, useState } from "react";
import DoctorAvatar from "@/app/component/DoctorAvatar";
import QrImg from "./QrImg";
import SiteLogo from "./SiteLogo";
import SiteLogo from "@/components/common/SiteLogo";
import { getStateInfoClient } from "@/lib/getStateInfoClient";
import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay";
import { doctorTitle } from "@/helper";
import StarDI from "@/components/icons/StarDI";
@@ -21,7 +22,9 @@ function PosterLight({ data, onQrReady }) {
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 shownServices = services.slice(0, 8);
const moreServices = services.length - shownServices.length;
@@ -44,7 +47,7 @@ function PosterLight({ data, onQrReady }) {
return (
<div
dir="rtl"
className="relative w-[1080px] h-[1350px] overflow-hidden flex flex-col bg-[#F6F9FD] text-center"
className="relative w-[1080px] h-[1527px] overflow-hidden flex flex-col bg-[#F6F9FD] text-center"
>
{/* top brand band */}
<div
@@ -68,7 +71,9 @@ function PosterLight({ data, onQrReady }) {
</div>
</div>
<div className="flex-1 px-[72px] flex flex-col items-center">
{/* بالا-چین می‌ماند تا آواتار روی نوار بالایی سوار بماند؛ فاصلهٔ اضافیِ کاغذ A4
به‌صورت فضای خالی بالای فوتر می‌نشیند، نه بین آواتار و نوار. */}
<div className="flex-1 px-[72px] pb-[40px] flex flex-col items-center">
{/* avatar overlapping band */}
<div className="-mt-[8px] pt-[40px]">
<div
@@ -88,18 +93,23 @@ function PosterLight({ data, onQrReady }) {
{doctorTitle(data?.name)}
</h1>
<div className="w-full flex flex-wrap justify-center gap-[10px] mt-[16px]">
{specialties.map((s, i) => (
<span
key={i}
className="flex items-center shrink-0 rounded-full bg-[#E3EEF9] px-[20px] py-[9px]"
>
{primary && (
<div className="w-full flex justify-center mt-[16px]">
<span 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">
{s.name}
{primary.name}
</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 && (
<div className="w-full flex flex-wrap justify-center items-center gap-[14px] mt-[22px]">
+27 -14
View File
@@ -2,8 +2,9 @@
import { useEffect, useState } from "react";
import DoctorAvatar from "@/app/component/DoctorAvatar";
import QrImg from "./QrImg";
import SiteLogoBase from "./SiteLogo";
import SiteLogoBase from "@/components/common/SiteLogo";
import { getStateInfoClient } from "@/lib/getStateInfoClient";
import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay";
import { doctorTitle } from "@/helper";
import StarDI from "@/components/icons/StarDI";
@@ -41,7 +42,10 @@ function Poster({ data, onQrReady }) {
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 shownServices = services.slice(0, 8);
const moreServices = services.length - shownServices.length;
@@ -80,7 +84,7 @@ function Poster({ data, onQrReady }) {
return (
<div
dir="rtl"
className="relative w-[1080px] h-[1350px] overflow-hidden flex flex-col p-[64px] text-right"
className="relative w-[1080px] h-[1527px] overflow-hidden flex flex-col p-[64px] text-right"
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]" />
@@ -128,18 +132,25 @@ function Poster({ data, onQrReady }) {
<h1 className="text-white text-[46px] font-extrabold leading-tight">
{doctorTitle(data?.name)}
</h1>
<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]"
>
{primary && (
<div className="mt-[16px] flex flex-wrap items-center gap-[12px]">
<span 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}
{primary.name}
</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]">
{badges.map((b, i) => (
<span
@@ -165,9 +176,11 @@ function Poster({ data, onQrReady }) {
</section>
{/* body */}
<section className="relative mt-[36px] flex-1">
{/* کارت‌ها به هم چسبیده می‌مانند و فاصلهٔ اضافیِ کاغذ A4 بالای فوتر می‌نشیند:
حفرهٔ وسطِ صفحه شبیه محتوای جاافتاده دیده می‌شد. */}
<section className="relative mt-[36px] flex-1 flex flex-col gap-[28px]">
{shownServices.length > 0 && (
<div className="rounded-[28px] bg-white/6 border border-white/10 p-[32px] mb-[28px]">
<div className="rounded-[28px] bg-white/6 border border-white/10 p-[32px]">
<SectionTitle icon={<ClipBoard />}>خدمات</SectionTitle>
<div className="flex flex-wrap items-center gap-x-[12px] gap-y-[14px]">
{shownServices.map((s, i) => (
+87
View File
@@ -0,0 +1,87 @@
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();
});
});
+75
View File
@@ -0,0 +1,75 @@
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();
});
});
+2 -13
View File
@@ -1,20 +1,9 @@
import specialties from "@/data/specialties.json";
import { nextSpecialtyFilter } from "@/helper";
import Form from "./form";
function Content({ filter, setFilter, updateData, setDataInURL }) {
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;
}
}
const newFilter = nextSpecialtyFilter(filter, name, value);
setFilter(newFilter);
setDataInURL(newFilter);
+6 -2
View File
@@ -24,9 +24,13 @@ function Form({ filter, changeSpecialty, updateData }) {
<CateSelector
name="specialty"
list={childrenList}
updateData={updateData}
updateData={changeSpecialty}
value={filter.specialty}
label="تخصص"
// خالی‌بودن یعنی کل گروه؛ برچسب همین را می‌گوید تا کاربر فکر نکند
// چیزی را جا انداخته.
label={
filter.category ? `همه تخصص‌های ${filter.category.name}` : "تخصص"
}
/>
</div>
<div className="mt-[40px] mb-[32px]">
+1 -1
View File
@@ -55,7 +55,7 @@ function SearchField({ filter, setFilter, setDataInURL, sendReq }) {
clearText={clearText}
inputValue={filter.name}
handleSearch={handleSearch}
placeholder="جستجوی نام پزشک ..."
placeholder="جستجوی نام پزشک یا تخصص ..."
/>
</div>
);
@@ -0,0 +1,62 @@
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;
@@ -0,0 +1,90 @@
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();
});
});
@@ -0,0 +1,66 @@
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,
]);
});
});
+11 -1
View File
@@ -1,10 +1,14 @@
import Pageguide from "@/app/component/Pageguide";
import { parentSpecialty } from "@/lib/specialtyIndexing";
import DoctorGrid from "./DoctorGrid";
import Faq from "./Faq";
import SpecialtyTree from "./SpecialtyTree";
// چیدمان عمداً هم‌خانوادهٔ /doctors است: همان padding-responsive، همان فاصلهٔ بالای صفحه،
// همان تایپوگرافی عنوان و همان گرید کارت پزشک — کاربر نباید حس کند وارد بخش دیگری شده.
function SpecialtyDetailPage({ specialtyName, cityName, doctors, intro, faq, origin, slug }) {
function SpecialtyDetailPage({ specialty, specialtyName, cityName, doctors, intro, faq, origin, slug }) {
const parent = parentSpecialty(specialty);
return (
<div className="pt-[95px] sm:pt-[120px] padding-responsive">
<Pageguide
@@ -12,6 +16,10 @@ function SpecialtyDetailPage({ specialtyName, cityName, doctors, intro, faq, ori
list={[
{ name: "خانه", href: "/" },
{ name: "تخصص‌ها", href: "/specialties" },
// زیرتخصص از دل گروهش می‌آید؛ بدون این حلقه، مسیر بازگشت به گروه فقط
// دستکاری URL بود. Pageguide همین فهرست را به BreadcrumbList هم می‌دهد،
// پس نسخهٔ بصری و schema واگرا نمی‌شوند.
...(parent ? [{ name: parent.name, href: `/specialties/${parent.slug}` }] : []),
// href صفحهٔ جاری: بدون آن `item` غایب می‌شود و اسکیما نامعتبر است
{ name: specialtyName, href: slug ? `/specialties/${slug}` : "/specialties" },
]}
@@ -21,6 +29,8 @@ function SpecialtyDetailPage({ specialtyName, cityName, doctors, intro, faq, ori
متخصص {specialtyName} در {cityName}
</h1>
<SpecialtyTree specialty={specialty} />
<p className="text-[#525252] text-[14px] md:text-[16px] font-normal leading-8 mt-[16px] text-justify">
{intro}
</p>
+6
View File
@@ -0,0 +1,6 @@
{
"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"
}
+46 -1
View File
@@ -844,5 +844,50 @@
"status": 1,
"weight": 0,
"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
}
]
]
+154
View File
@@ -0,0 +1,154 @@
# خطاهای 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 | پیامد دو مورد بالا | بعد از رفع آن‌ها دوباره سنجیده شود |
+21 -1
View File
@@ -411,5 +411,25 @@
"409": "Community 409",
"410": "Community 410",
"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"
}
+113 -67
View File
@@ -1,16 +1,16 @@
# Graph Report - nobat724_front (2026-07-30)
# Graph Report - nobat724_front (2026-08-09)
## Corpus Check
- 642 files · ~582,216 words
- 669 files · ~597,587 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 2473 nodes · 2548 edges · 413 communities (377 shown, 36 thin omitted)
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 176 edges (avg confidence: 0.8)
- 2625 nodes · 2701 edges · 433 communities (386 shown, 47 thin omitted)
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 194 edges (avg confidence: 0.8)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `8a24579a`
- Built from commit: `002f388a`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -139,6 +139,7 @@
- [[_COMMUNITY_Community 126|Community 126]]
- [[_COMMUNITY_Community 128|Community 128]]
- [[_COMMUNITY_Community 140|Community 140]]
- [[_COMMUNITY_Community 188|Community 188]]
- [[_COMMUNITY_Community 195|Community 195]]
- [[_COMMUNITY_Community 339|Community 339]]
- [[_COMMUNITY_Community 347|Community 347]]
@@ -183,35 +184,52 @@
- [[_COMMUNITY_Community 410|Community 410]]
- [[_COMMUNITY_Community 411|Community 411]]
- [[_COMMUNITY_Community 412|Community 412]]
- [[_COMMUNITY_Community 413|Community 413]]
- [[_COMMUNITY_Community 414|Community 414]]
- [[_COMMUNITY_Community 415|Community 415]]
- [[_COMMUNITY_Community 416|Community 416]]
- [[_COMMUNITY_Community 417|Community 417]]
- [[_COMMUNITY_Community 418|Community 418]]
- [[_COMMUNITY_Community 419|Community 419]]
- [[_COMMUNITY_Community 420|Community 420]]
- [[_COMMUNITY_Community 421|Community 421]]
- [[_COMMUNITY_Community 422|Community 422]]
- [[_COMMUNITY_Community 423|Community 423]]
- [[_COMMUNITY_Community 425|Community 425]]
- [[_COMMUNITY_Community 426|Community 426]]
- [[_COMMUNITY_Community 427|Community 427]]
- [[_COMMUNITY_Community 428|Community 428]]
- [[_COMMUNITY_Community 429|Community 429]]
- [[_COMMUNITY_Community 430|Community 430]]
## God Nodes (most connected - your core abstractions)
1. `getStateInfo()` - 29 edges
1. `getStateInfo()` - 30 edges
2. `🏥 نوبت724 (Nobat724) - سیستم نوبت‌دهی آنلاین پزشکی` - 19 edges
3. `تقسیم ممیزی SEO به پرامپت‌های کوچک اجرایی — nobat724` - 18 edges
4. `imageUrl()` - 16 edges
5. `Blog()` - 15 edges
4. `Blog()` - 17 edges
5. `imageUrl()` - 16 edges
6. `Clinic()` - 13 edges
7. `Doctor()` - 12 edges
8. `generateMetadata()` - 10 edges
7. `Doctor()` - 13 edges
8. `generateMetadata()` - 12 edges
9. `Dashboard()` - 10 edges
10. `sitemap()` - 10 edges
## Surprising Connections (you probably didn't know these)
- `Blog()` --calls--> `normalizeBlog()` [INFERRED]
app/blog/[slug]/page.js → helper/index.js
- `AboutUsPage()` --calls--> `getStateInfo()` [INFERRED]
components/aboutUs/index.js → lib/getStateInfo.js
- `ContactUsPage()` --calls--> `getStateInfo()` [INFERRED]
components/contactUs/index.js → lib/getStateInfo.js
- `SpecialtyDetail()` --calls--> `buildSpecialtyFaq()` [INFERRED]
app/specialties/[slug]/page.js → lib/specialtyContent.js
- `SearchBar()` --calls--> `getStateInfo()` [INFERRED]
components/home/search/index.js → lib/getStateInfo.js
- `StLayout()` --calls--> `getStateInfo()` [INFERRED]
components/layout/StLayout.js → lib/getStateInfo.js
- `ProvinceProvider()` --calls--> `isRootCity()` [INFERRED]
context/ProvinceProvider.js → lib/rootCity.js
## Import Cycles
- None detected.
## Communities (413 total, 36 thin omitted)
## Communities (433 total, 47 thin omitted)
### Community 0 - "Community 0"
Cohesion: 0.05
@@ -230,12 +248,12 @@ Cohesion: 0.23
Nodes (16): getCurrentDomain(), robots(), cityFilterParams(), fetchAllPages(), getBlogUrls(), getCityScope(), getClinicUrls(), getCurrentDomain() (+8 more)
### Community 4 - "Community 4"
Cohesion: 0.05
Nodes (33): ContentDetail(), About(), ListBime(), Services(), Specialties(), TextDetail(), listTab, AddComment() (+25 more)
Cohesion: 0.06
Nodes (29): ContentDetail(), About(), ListBime(), Services(), Specialties(), TextDetail(), listTab, AddComment() (+21 more)
### Community 5 - "Community 5"
Cohesion: 0.07
Nodes (26): bookingState(), DAY_NAMES, formatJalali(), AppointmentList(), ItemAppointment(), AboutDcotor(), ClaimProfileSection(), currentUserMobile() (+18 more)
Cohesion: 0.05
Nodes (37): bookingState(), DAY_NAMES, formatJalali(), AppointmentList(), ItemAppointment(), AboutDcotor(), ClaimProfileSection(), currentUserMobile() (+29 more)
### Community 6 - "Community 6"
Cohesion: 0.22
@@ -270,16 +288,16 @@ Cohesion: 0.10
Nodes (20): `app/doctor/[slug]/page.js` — دو فراخوانی تکراری, `app/doctors/page.js` — بدون og:images، بدون cache, `lib/req.js` — مشکل اصلی caching, `middleware.js` — اجرا روی همه مسیرها, بازبینی کامل Rendering Strategy، SEO، Performance و امنیت (Next.js 15), زمینه, فایل‌های مرتبط, نکات مهم (+12 more)
### Community 14 - "Community 14"
Cohesion: 0.15
Nodes (10): Call(), Links(), Connect(), ContentContact(), FieldLabel(), ContactUsPage(), ArrowLeftC(), CallC() (+2 more)
Cohesion: 0.08
Nodes (22): Head(), AboutUsPage(), ourServices, Services(), Call(), Links(), Connect(), ContentContact() (+14 more)
### Community 15 - "Community 15"
Cohesion: 0.14
Nodes (10): Chart(), ProgressAll(), fallbackLabels, ProgressDetail(), Form(), Content(), Rates(), numberToArStyle() (+2 more)
### Community 16 - "Community 16"
Cohesion: 0.25
Nodes (6): Head(), AboutUsPage(), ourServices, Services(), TickOrangeA(), UnderlineLG()
Cohesion: 0.06
Nodes (30): بازگرداندن صفحهٔ اصلی، رفع ۴۰۴ اسلاگ فارسی، سه اصلاح صفحهٔ مقاله، و دو اصلاح «درباره ما», برچسب شهر, تگ‌های بدون لینک, زمینه, صفحهٔ اصلی, صفحهٔ تخصص — دو مصرف متفاوت از یک param, عکس مطالب مرتبط, فایل‌های مرتبط (+22 more)
### Community 17 - "Community 17"
Cohesion: 0.11
@@ -330,8 +348,8 @@ Cohesion: 0.12
Nodes (16): دیپلوی nobat724_front روی Liara (پلتفرم Next.js), زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, پروژه (+8 more)
### Community 29 - "Community 29"
Cohesion: 0.13
Nodes (11): generateMetadata(), Blogs(), generateMetadata(), Appointment(), metadata, fetchSiteContext(), getStateInfo(), siteContextCache (+3 more)
Cohesion: 0.11
Nodes (12): generateMetadata(), Blogs(), generateMetadata(), generateMetadata(), Appointment(), metadata, fetchSiteContext(), getStateInfo() (+4 more)
### Community 30 - "Community 30"
Cohesion: 0.12
@@ -394,8 +412,8 @@ Cohesion: 0.14
Nodes (13): `hours/List.js`, آبجکت اسلات (از `adaptSlots` / API), تأیید و سخت‌سازی غیرفعال‌بودن اسلات‌های گذشته در صفحه‌ی نوبت, زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی (کد واقعی) (+5 more)
### Community 45 - "Community 45"
Cohesion: 0.20
Nodes (20): generateMetadata(), generateMetadata(), generateMetadata(), DoctorPage(), blogCover(), doctorTitle(), imageUrl(), domainScopeCityId() (+12 more)
Cohesion: 0.36
Nodes (11): generateMetadata(), blogCover(), imageUrl(), domainScopeCityId(), findCityById(), isBlogVisibleOnDomain(), getBlogCanonicalOrigin(), Blog() (+3 more)
### Community 46 - "Community 46"
Cohesion: 0.22
@@ -438,8 +456,8 @@ Cohesion: 0.27
Nodes (5): Field(), ItemUser(), Replies(), SendAnswer(), timeAgo()
### Community 56 - "Community 56"
Cohesion: 0.27
Nodes (5): DoctorAvatar(), getInitials(), CircularLoading(), CustomLoading(), TextLoading()
Cohesion: 0.23
Nodes (6): DoctorAvatar(), getInitials(), SpecialtyChips(), CircularLoading(), CustomLoading(), TextLoading()
### Community 57 - "Community 57"
Cohesion: 0.18
@@ -498,12 +516,12 @@ Cohesion: 0.20
Nodes (7): AutoComplete(), ButtonFilter(), Content(), ModalSearchCity(), AutoSearch(), FilterButton(), SearchBar()
### Community 71 - "Community 71"
Cohesion: 0.22
Nodes (7): degree, Form(), gender, degree, Form(), gender, filterList()
Cohesion: 0.20
Nodes (8): degree, Form(), gender, degree, Form(), gender, allOfCategoryOption(), filterList()
### Community 72 - "Community 72"
Cohesion: 0.24
Nodes (15): generateMetadata(), findDomainByCityId(), buildCanonicalPath(), buildCanonicalQuery(), buildCanonicalUrl(), buildFilteredListingCanonical(), buildMainCanonicalUrl(), CANONICAL_QUERY_KEYS (+7 more)
Cohesion: 0.26
Nodes (13): generateMetadata(), findDomainByCityId(), buildCanonicalPath(), buildCanonicalQuery(), buildCanonicalUrl(), buildFilteredListingCanonical(), buildMainCanonicalUrl(), CANONICAL_QUERY_KEYS (+5 more)
### Community 73 - "Community 73"
Cohesion: 0.32
@@ -538,8 +556,8 @@ Cohesion: 0.24
Nodes (5): Head(), Article(), LatestArticles(), Title(), ItemTitle()
### Community 86 - "Community 86"
Cohesion: 0.27
Nodes (10): generateMetadata(), bodyVariants(), buildSpecialtyFaq(), buildSpecialtyIntro(), closingVariants(), openingVariants(), pick(), findSpecialty() (+2 more)
Cohesion: 0.52
Nodes (5): generateMetadata(), decodeRouteParam(), findSpecialty(), getDoctors, SpecialtyDetail()
### Community 87 - "Community 87"
Cohesion: 0.33
@@ -614,8 +632,12 @@ Cohesion: 0.15
Nodes (12): اصلاح بخش «اطلاعات تماس» صفحه کلینیک, زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, پروژه (+4 more)
### Community 140 - "Community 140"
Cohesion: 0.36
Nodes (6): RootLayout(), Pageguide(), absoluteUrl(), buildBreadcrumbJsonLd(), normalizeBreadcrumb(), safeJsonLd()
Cohesion: 0.53
Nodes (4): Pageguide(), absoluteUrl(), buildBreadcrumbJsonLd(), normalizeBreadcrumb()
### Community 188 - "Community 188"
Cohesion: 0.09
Nodes (22): انتخاب اجباری اولین زیرتخصص, زمینه, فایل‌های مرتبط, مشکل / هدف, معیار پذیرش, نمایش چندتخصصی پزشک در کارت، فیلترها و پوستر, نکات مهم, وضعیت فعلی (+14 more)
### Community 354 - "Community 354"
Cohesion: 0.11
@@ -642,12 +664,16 @@ Cohesion: 0.29
Nodes (6): Doctors(), buildDoctorParams(), normalizeDoctorNameQuery(), QueryForDoctorsClinicReq(), QueryForDoctorsReq(), buildSearchUrl()
### Community 370 - "Community 370"
Cohesion: 0.25
Nodes (5): ProvinceContext, ProvinceProvider(), Probe(), useProvince(), SubmitData()
Cohesion: 0.29
Nodes (4): ProvinceContext, ProvinceProvider(), Probe(), useProvince()
### Community 371 - "Community 371"
Cohesion: 0.24
Nodes (8): DoctorGrid(), SpecialtyDetailPage(), SpecialtyTree(), childSpecialties(), IDS_WITH_ACTIVE_CHILDREN, isCategorySpecialty(), isIndexableSpecialty(), parentSpecialty()
### Community 372 - "Community 372"
Cohesion: 0.48
Nodes (6): hasRealName(), isNoindexDoctor(), isThinClinic(), isThinDoctor(), isUnclaimedDoctor(), PLACEHOLDER_NAMES
Cohesion: 0.24
Nodes (13): generateMetadata(), generateMetadata(), toE164Ir(), extractEntityCityId(), hasRealName(), isNoindexDoctor(), isThinClinic(), isThinDoctor() (+5 more)
### Community 373 - "Community 373"
Cohesion: 0.12
@@ -689,6 +715,10 @@ Nodes (10): زمینه, فعال‌سازی بلاگ شهر-محور (بعد ا
Cohesion: 0.20
Nodes (9): زمینه, ساده‌سازی sitemap با شهرِ موجود در پاسخ + آستانهٔ sitemap-index, نکات مهم, وضعیت فعلی, وظایف, پروژه, ۱. حذف ۳۵ sweep و یکسان‌سازی با الگوی کلینیک, ۲. آستانهٔ sitemap-index (مستقل از backend) (+1 more)
### Community 388 - "Community 388"
Cohesion: 0.36
Nodes (3): Form(), SubmitData(), ButtonFixed()
### Community 390 - "Community 390"
Cohesion: 0.40
Nodes (3): Allergies(), ItemAllergie(), ModalAddAlergie()
@@ -705,17 +735,13 @@ Nodes (3): ModalAddRelatives(), Relatives(), ItemRelatives()
Cohesion: 0.40
Nodes (3): ModalAddSurgeries(), Surgeries(), ItemSurgeries()
### Community 395 - "Community 395"
Cohesion: 0.31
Nodes (3): Content(), Form(), EditField()
### Community 397 - "Community 397"
Cohesion: 0.50
Nodes (3): formatToman(), rialToToman(), Paying()
### Community 402 - "Community 402"
Cohesion: 0.21
Nodes (10): ClinicsPage(), DoctorsPage(), resolveCityDisplayName(), buildClinicsIntro(), buildDoctorsIntro(), CLINIC_INTROS(), DOCTOR_INTROS(), pick() (+2 more)
Cohesion: 0.36
Nodes (5): ClinicsPage(), DoctorsPage(), resolveCityDisplayName(), isRootCity(), ListDoctors()
### Community 403 - "Community 403"
Cohesion: 0.29
@@ -726,40 +752,60 @@ Cohesion: 0.10
Nodes (19): زمینه, صفحهٔ بلاگ: بردکرامب دسته‌بندی، فیلتر دسته‌بندی، و اصلاح ستون «مطالب مرتبط», فایل‌های مرتبط, مشکل / هدف, معیار پذیرش, نکات مهم, وضعیت فعلی, وظایف (+11 more)
### Community 408 - "Community 408"
Cohesion: 0.17
Nodes (8): Clinics(), generateMetadata(), buildClinicParams(), isMaintenanceError(), isNextRedirectError(), axiosInstance, fetchReq(), getClinicDoctors()
Cohesion: 0.13
Nodes (12): Clinics(), generateMetadata(), buildClinicParams(), buildClinicsIntro(), CLINIC_INTROS(), countSentence(), pick(), isMaintenanceError() (+4 more)
### Community 409 - "Community 409"
Cohesion: 0.25
Nodes (8): scripts, build, dev, lint, start, test, test:cov, test:watch
Cohesion: 0.22
Nodes (9): scripts, build, dev, lint, prebuild, start, test, test:cov (+1 more)
### Community 410 - "Community 410"
Cohesion: 0.33
Nodes (5): engines, node, name, private, version
### Community 411 - "Community 411"
Cohesion: 0.67
Nodes (3): IDS_WITH_ACTIVE_CHILDREN, isCategorySpecialty(), isIndexableSpecialty()
Cohesion: 0.11
Nodes (18): اسکلتی که uuid ندارد, الگوی متنِ یکتا که از قبل هست و باید تقلید شود, تنها بخش شهرمحور صفحهٔ اصلی, رفع لینک `/doctor/undefined` و متمایزسازی صفحهٔ اصلی شهرها, زمینه, فایل‌های مرتبط, لینک با uuid اختیاری, مشکل / هدف (+10 more)
### Community 413 - "Community 413"
Cohesion: 0.39
Nodes (7): RootLayout(), getRequestOrigin(), safeJsonLd(), Doctor(), getBookingLocations, getDoctor, getDoctorAddresses
### Community 414 - "Community 414"
Cohesion: 0.25
Nodes (7): خطاهای Search Console که باگ نیستند, خلاصهٔ تصمیم, ۱. Excluded by 'noindex' tag, ۲. Alternate page with proper canonical tag, ۳. Page with redirect, ۴. آدرس‌های قدیمی بلاگ که ۴۰۴ می‌دهند, ۵. Discovered — currently not indexed
### Community 416 - "Community 416"
Cohesion: 0.33
Nodes (4): before, OUT, ROOT, sorted
### Community 417 - "Community 417"
Cohesion: 0.50
Nodes (3): gi, root, thyroid
### Community 419 - "Community 419"
Cohesion: 0.50
Nodes (3): gi, root, thyroid
## Knowledge Gaps
- **945 isolated node(s):** `metadata`, `fallbackLabels`, `MaterialUISwitch`, `fixedIconData`, `metadata` (+940 more)
- **1035 isolated node(s):** `metadata`, `DOCTOR`, `fallbackLabels`, `root`, `gi` (+1030 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **36 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
- **47 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `imageUrl()` connect `Community 45` to `Community 0`, `Community 4`, `Community 7`, `Community 392`, `Community 83`, `Community 55`, `Community 56`, `Community 380`?**
_High betweenness centrality (0.028) - this node is a cross-community bridge._
- **Why does `getStateInfo()` connect `Community 29` to `Community 89`, `Community 0`, `Community 6`, `Community 72`, `Community 140`, `Community 45`, `Community 14`, `Community 16`, `Community 369`, `Community 402`, `Community 51`, `Community 86`, `Community 408`, `Community 377`?**
_High betweenness centrality (0.025) - this node is a cross-community bridge._
- **Why does `doctorTitle()` connect `Community 45` to `Community 5`, `Community 7`?**
_High betweenness centrality (0.012) - this node is a cross-community bridge._
- **Are the 27 inferred relationships involving `getStateInfo()` (e.g. with `generateMetadata()` and `AboutUsPage()`) actually correct?**
_`getStateInfo()` has 27 INFERRED edges - model-reasoned connections that need verification._
- **Are the 14 inferred relationships involving `imageUrl()` (e.g. with `generateMetadata()` and `generateMetadata()`) actually correct?**
_`imageUrl()` has 14 INFERRED edges - model-reasoned connections that need verification._
- **What connects `metadata`, `fallbackLabels`, `MaterialUISwitch` to the rest of the system?**
_945 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Why does `getStateInfo()` connect `Community 29` to `Community 89`, `Community 0`, `Community 6`, `Community 72`, `Community 45`, `Community 14`, `Community 369`, `Community 402`, `Community 51`, `Community 372`, `Community 86`, `Community 408`, `Community 377`, `Community 413`?**
_High betweenness centrality (0.033) - this node is a cross-community bridge._
- **Why does `imageUrl()` connect `Community 45` to `Community 0`, `Community 4`, `Community 7`, `Community 392`, `Community 83`, `Community 372`, `Community 55`, `Community 56`, `Community 380`, `Community 413`?**
_High betweenness centrality (0.029) - this node is a cross-community bridge._
- **Why does `isRootCity()` connect `Community 402` to `Community 3`, `Community 72`, `Community 45`, `Community 370`, `Community 29`?**
_High betweenness centrality (0.010) - this node is a cross-community bridge._
- **Are the 28 inferred relationships involving `getStateInfo()` (e.g. with `generateMetadata()` and `AboutUsPage()`) actually correct?**
_`getStateInfo()` has 28 INFERRED edges - model-reasoned connections that need verification._
- **Are the 13 inferred relationships involving `Blog()` (e.g. with `blogCover()` and `imageUrl()`) actually correct?**
_`Blog()` has 13 INFERRED edges - model-reasoned connections that need verification._
- **What connects `metadata`, `DOCTOR`, `fallbackLabels` to the rest of the system?**
_1035 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `Community 0` be split into smaller, more focused modules?**
_Cohesion score 0.05263157894736842 - nodes in this community are weakly interconnected._
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [], "edges": [], "skipped": "data json (not a config/manifest)"}
@@ -0,0 +1 @@
{"nodes": [], "edges": [], "skipped": "data json (non-object root)"}
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3535 -458
View File
File diff suppressed because it is too large Load Diff
+420 -290
View File
File diff suppressed because it is too large Load Diff
+41 -8
View File
@@ -267,20 +267,53 @@ export function clearDoctorClinicParams(router, slug) {
const newUrl = `/clinic/${clinicslug}`;
router.push(newUrl)
}
/** گزینهٔ «کل گروه» در فهرست زیرتخصص‌ها؛ با id تهی، چون حالتش نبودِ زیرتخصص است. */
export const allOfCategoryOption = (category) => ({
id: null,
name: `همه تخصص‌های ${category.name}`,
isAll: true,
});
/**
* فیلتر بعدی وقتی کاربر گروه یا زیرتخصص را عوض میکند.
*
* تابع خالص است چون همین قاعده بود که شکسته بود: انتخاب یک گروه بیصدا اولین
* زیرتخصصش را مینشاند و جستجوی کاربر را تنگ میکرد. داخل کامپوننت، تستپذیر نبود.
*/
export const nextSpecialtyFilter = (filter, name, value) => {
const next = { ...filter, [name]: value };
// گروه یعنی کل گروه، نه اولین فرزندش.
if (name === "category") next.specialty = null;
// «همه تخصص‌های X» زیرتخصص واقعی نیست؛ حالتش همان نبودِ زیرتخصص است. اگر خودش
// در فیلتر می‌نشست، نامش در URL می‌رفت و چون در specialties.json نیست، با رفرش
// یا اشتراک لینک بازسازی نمی‌شد.
if (name === "specialty" && value?.isAll) next.specialty = null;
return next;
};
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)
)
: [];
if (!data?.category) {
return { parentList, childrenList: [] };
}
const ofCategory = childrenList.filter(
(item) => String(item.parent_id) === String(data.category.id)
);
// «همه» اول فهرست می‌نشیند تا برگشت از یک زیرتخصص به کل گروه ممکن باشد.
// خودش وارد فیلتر نمی‌شود؛ انتخابش یعنی specialty تهی، و آن‌وقت QueryForDoctorsReq
// به category.id برمی‌گردد که بک‌اند به همهٔ زیرشاخه‌ها گسترشش می‌دهد.
return {
parentList: parentList,
childrenList: filteredChildrenList,
parentList,
childrenList: ofCategory.length
? [allOfCategoryOption(data.category), ...ofCategory]
: [],
};
};
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it } from "vitest";
import specialtiesData from "@/data/specialties.json";
import {
QueryForDoctorsFilter,
QueryForDoctorsReq,
filterList,
nextSpecialtyFilter,
} from "@/helper";
const byName = (name) => specialtiesData.find((s) => s.name === name);
const GENERAL_SURGERY = byName("جراحی عمومی");
describe("filterList — گزینهٔ «همه تخصص‌های X»", () => {
it("بدون گروه، فهرست زیرتخصص خالی است", () => {
expect(filterList({}).childrenList).toEqual([]);
expect(filterList(null).childrenList).toEqual([]);
});
it("با گروه، «همه» اولین گزینه است و id گروه را ندارد", () => {
const { childrenList } = filterList({ category: GENERAL_SURGERY });
expect(childrenList[0]).toMatchObject({
id: null,
isAll: true,
name: `همه تخصص‌های ${GENERAL_SURGERY.name}`,
});
});
it("زیرتخصص‌های واقعی بعد از «همه» می‌آیند", () => {
const { childrenList } = filterList({ category: GENERAL_SURGERY });
const real = childrenList.slice(1);
expect(real.length).toBeGreaterThan(0);
expect(
real.every((s) => String(s.parent_id) === String(GENERAL_SURGERY.id))
).toBe(true);
});
it("زیرتخصص‌های تازهٔ همگام‌شده هم در فهرست هستند", () => {
// این پنج تا در اسنپ‌شات دستیِ قبلی نبودند؛ اگر همگام‌سازی برگردد، اینجا قرمز می‌شود.
const names = filterList({ category: GENERAL_SURGERY }).childrenList.map(
(s) => s.name
);
expect(names).toContain("جراح گوارش");
expect(names).toContain("جراح تیروئید");
});
it("گروهِ بدون زیرشاخه گزینهٔ «همه» نمی‌گیرد", () => {
const leafRoot = specialtiesData.find(
(s) =>
!s.parent_id &&
!specialtiesData.some((c) => String(c.parent_id) === String(s.id))
);
expect(filterList({ category: leafRoot }).childrenList).toEqual([]);
});
it("فهرست گروه‌ها فقط ریشه‌هاست", () => {
expect(filterList({}).parentList.every((s) => !s.parent_id)).toBe(true);
});
});
describe("nextSpecialtyFilter — قاعدهٔ انتخاب", () => {
it("انتخاب گروه، زیرتخصص را تهی می‌گذارد نه اولین فرزند", () => {
const firstChild = filterList({ category: GENERAL_SURGERY }).childrenList[1];
const next = nextSpecialtyFilter({}, "category", GENERAL_SURGERY);
expect(next.category).toBe(GENERAL_SURGERY);
expect(next.specialty).toBeNull();
expect(next.specialty).not.toEqual(firstChild);
});
it("عوض کردن گروه، زیرتخصصِ گروه قبلی را پاک می‌کند", () => {
const child = filterList({ category: GENERAL_SURGERY }).childrenList[1];
const other = specialtiesData.find(
(s) => !s.parent_id && s.id !== GENERAL_SURGERY.id
);
const next = nextSpecialtyFilter(
{ category: GENERAL_SURGERY, specialty: child },
"category",
other
);
expect(next.specialty).toBeNull();
});
it("انتخاب «همه» زیرتخصص را تهی می‌کند", () => {
const all = filterList({ category: GENERAL_SURGERY }).childrenList[0];
const next = nextSpecialtyFilter(
{ category: GENERAL_SURGERY },
"specialty",
all
);
expect(next.specialty).toBeNull();
});
it("انتخاب یک زیرتخصص واقعی همان را می‌نشاند", () => {
const child = filterList({ category: GENERAL_SURGERY }).childrenList[1];
const next = nextSpecialtyFilter(
{ category: GENERAL_SURGERY },
"specialty",
child
);
expect(next.specialty).toBe(child);
});
it("پاک کردن گروه هر دو را تهی می‌کند", () => {
const next = nextSpecialtyFilter(
{ category: GENERAL_SURGERY, specialty: { id: 1 } },
"category",
null
);
expect(next.category).toBeNull();
expect(next.specialty).toBeNull();
});
it("فیلترهای دیگر دست‌نخورده می‌مانند", () => {
const next = nextSpecialtyFilter(
{ city: { id: 7 }, gender: "زن" },
"category",
GENERAL_SURGERY
);
expect(next.city).toEqual({ id: 7 });
expect(next.gender).toBe("زن");
});
});
describe("انتخاب کل گروه — پارامترها", () => {
it("بدون زیرتخصص، درخواست با شناسهٔ گروه می‌رود", () => {
// بک‌اند specialty_id را به همهٔ زیرشاخه‌ها گسترش می‌دهد.
const params = QueryForDoctorsReq({ category: GENERAL_SURGERY, specialty: null });
expect(params.specialty_id).toBe(GENERAL_SURGERY.id);
});
it("با زیرتخصص، همان زیرتخصص بر گروه مقدم است", () => {
const child = filterList({ category: GENERAL_SURGERY }).childrenList[1];
const params = QueryForDoctorsReq({ category: GENERAL_SURGERY, specialty: child });
expect(params.specialty_id).toBe(child.id);
});
it("URL نام گروه را می‌گیرد، نه عنوان مصنوعیِ «همه …»", () => {
// عنوان «همه تخصص‌های …» در specialties.json نیست؛ اگر در URL می‌نشست،
// با رفرش یا اشتراک لینک بازسازی نمی‌شد.
const query = QueryForDoctorsFilter({ category: GENERAL_SURGERY, specialty: null });
expect(query.specialty).toBe(GENERAL_SURGERY.name);
expect(specialtiesData.some((s) => s.name === query.specialty)).toBe(true);
});
it("نامِ رفته در URL همیشه در فایل تخصص‌ها پیدا می‌شود", () => {
const child = filterList({ category: GENERAL_SURGERY }).childrenList[1];
const query = QueryForDoctorsFilter({ category: GENERAL_SURGERY, specialty: child });
expect(specialtiesData.some((s) => s.name === query.specialty)).toBe(true);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { buildClinicsIntro } from "@/lib/listingIntro";
describe("جملهٔ آماری متن صفحات لیست", () => {
it("دو شهرِ هم‌variant با آمار متفاوت، متن یکسان نمی‌گیرند", () => {
// بهبهان و یاسوج روی CLINIC_INTROS هم‌variant‌اند؛ پیش از افزودن عدد، متنشان
// تقریباً یکی می‌شد و شباهت /clinics از ۶۸.۹٪ به ۸۴.۷٪ رفت.
const a = buildClinicsIntro("بهبهان", 12);
const b = buildClinicsIntro("یاسوج", 31);
expect(a).not.toBe(b);
expect(a).toContain("12");
expect(b).toContain("31");
});
it("آمار صفر جمله را اضافه نمی‌کند", () => {
const withZero = buildClinicsIntro("بهبهان", 0);
const without = buildClinicsIntro("بهبهان");
expect(withZero).toBe(without);
expect(withZero).not.toContain("0 مرکز درمانی");
});
it("ورودی نامعتبر جمله را اضافه نمی‌کند", () => {
const text = buildClinicsIntro("بهبهان", "abc");
expect(text).not.toContain("NaN");
expect(text).toBe(buildClinicsIntro("بهبهان"));
});
it("نام شهر و واحد درست در جمله می‌آید", () => {
expect(buildClinicsIntro("یزد", 9)).toContain("9 مرکز درمانی در یزد");
});
});
+5 -10
View File
@@ -23,21 +23,16 @@ export function isThinDoctor(doctor) {
return !hasSpecialty && !hasLocation;
}
/**
* پروفایل بدون مالک (claim نشده). دادهاش از منبع بیرونی آمده و صاحبش تأییدش نکرده،
* پس محتوای قابل اتکا برای ایندکس نیست. هم پاسخ لیست و هم پاسخ جزئیات `owner_status`
* دارند؛ نبودِ فیلد هم «مالک ندارد» تفسیر میشود تا هیچوقت بهطور خاموش ایندکس نشود.
*/
export function isUnclaimedDoctor(doctor) {
return doctor?.owner_status !== "claimed";
}
/**
* منبع واحدِ ایندکسپذیری پزشک: هم صفحهٔ پزشک (robots) و هم sitemap از این استفاده
* میکنند تا واگرا نشوند چیزی که noindex است نباید در sitemap باشد.
*
* سنجه فقط محتواست، نه مالکیت. شرط `owner_status !== "claimed"` برداشته شد چون
* ۸۰ صفحهٔ یاسوج و ۱۲ صفحهٔ بهبهان را از ایندکس بیرون نگه میداشت پروفایلهایی که
* نام و تخصص و نشانی داشتند. claim یک وضعیت اداری است، نه سنجهٔ کیفیت محتوا.
*/
export function isNoindexDoctor(doctor) {
return isThinDoctor(doctor) || isUnclaimedDoctor(doctor);
return isThinDoctor(doctor);
}
/**
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { isNoindexDoctor, isThinDoctor } from "@/lib/entityQuality";
const DOCTOR = {
uuid: "1a55deaa-6c43-440d-b126-5870521ebc26",
name: "دکتر رضایی",
specialties: [{ name: "قلب و عروق" }],
address: [{ address: "یاسوج، خیابان اصلی" }],
};
describe("ایندکس‌پذیری پزشک", () => {
it("پروفایل claim نشدهٔ دارای محتوا ایندکس می‌شود", () => {
// پیش از این، همین حالت ۸۰ صفحهٔ یاسوج را noindex می‌کرد.
expect(isNoindexDoctor({ ...DOCTOR, owner_status: "unclaimed" })).toBe(false);
expect(isNoindexDoctor({ ...DOCTOR, owner_status: undefined })).toBe(false);
});
it("پروفایل claim شده هم ایندکس می‌شود", () => {
expect(isNoindexDoctor({ ...DOCTOR, owner_status: "claimed" })).toBe(false);
});
it("پروفایل بی‌محتوا همچنان noindex می‌ماند", () => {
expect(
isNoindexDoctor({ uuid: DOCTOR.uuid, name: "دکتر رضایی" })
).toBe(true);
expect(isNoindexDoctor({ ...DOCTOR, name: "09121234567" })).toBe(true);
expect(isNoindexDoctor({ ...DOCTOR, uuid: undefined })).toBe(true);
});
it("تخصص یا نشانی، هرکدام به‌تنهایی کافی است", () => {
expect(isThinDoctor({ ...DOCTOR, address: [] })).toBe(false);
expect(isThinDoctor({ ...DOCTOR, specialties: [] })).toBe(false);
});
});
+9 -8
View File
@@ -6,19 +6,20 @@ import { findDomainByCityId } from "@/lib/domainHelpers";
// → self-canonical روی همان دامنهٔ شهری (هر شهر محتوای خودش را دارد)
// C1-b موجودیت : /doctor/[uuid]، /clinic/[uuid]، /blog/[slug]
// → دامنهٔ شهرِ همان موجودیت (getEntityCanonical)
// C1-c استاتیک : /about-us، /contact-us، … → دامنهٔ اصلی
// (محتوا روی همهٔ دامنه‌ها یکسان است — تجمیع عمدی اعتبار)
// C1-c استاتیک : /terms، /privacy → دامنهٔ اصلی
// (متن حقوقی روی همهٔ دامنه‌ها کلمه‌به‌کلمه یکسان است)
//
// /about-us و /contact-us از C1-c بیرون آمدند: هر شهر آنجا داده و متن خودش را دارد
// (نام سایت، تلفن، نشانی، شبکه‌های اجتماعی، متن شهری)، پس self-canonical می‌شوند.
// پیش از این هر دو به دامنهٔ اصلی canonical می‌شدند و Search Console آن‌ها را
// «Alternate page with proper canonical tag» گزارش می‌کرد؛ یعنی روی هیچ دامنهٔ شهری
// ایندکس نمی‌شدند.
const DEFAULT_HOST = "nobat724.com";
const MAIN_ORIGIN = "https://nobat724.com";
// C1-c — صفحات با محتوای یکسان روی همهٔ دامنه‌ها
const STATIC_SHARED_PAGES = new Set([
"/about-us",
"/contact-us",
"/terms",
"/privacy",
]);
const STATIC_SHARED_PAGES = new Set(["/terms", "/privacy"]);
// تنها queryهایی که صفحه را از نظر محتوایی متمایز می‌کنند و باید در canonical بمانند.
// بقیه (فیلتر چندگانه، sort، name) حذف می‌شوند تا duplicate نسازند.
+25 -15
View File
@@ -1,4 +1,4 @@
// متن مقدمهٔ یکتای صفحات لیست (/doctors و /clinics).
// متن مقدمهٔ یکتای صفحهٔ لیست /clinics.
// canonical درست، محتوای یکسان را غیرتکراری نمی‌کند — این متن همان نقش مکمل را دارد.
// مثل صفحات تخصص، ساختار جمله‌بندی هر شهر با هش نام شهر انتخاب می‌شود تا ۳۵ دامنه
// ۳۵ متن با ساختار متفاوت داشته باشند، نه یک قالب با نام جای‌گذاری‌شده.
@@ -12,22 +12,32 @@ function hash(value) {
// seed همیشه unsigned — با >> علامت‌دار، هش‌های بزرگ ایندکس منفی می‌ساختند.
const pick = (variants, seed) => variants[(seed >>> 0) % variants.length];
const DOCTOR_INTROS = (city) => [
`فهرست پزشکان ${city} در این صفحه گردآوری شده است؛ از پزشک عمومی تا فوق‌تخصص‌های حوزه‌های مختلف. هر پروفایل شامل تخصص، کد نظام پزشکی، محل مطب و بازخورد بیماران پیشین است. با فیلترهای بالای صفحه می‌توانید نتایج را بر اساس تخصص، جنسیت پزشک یا درجهٔ تخصصی محدود کنید تا سریع‌تر به گزینهٔ مناسب برسید. پس از انتخاب پزشک، تقویم نوبت‌های خالی نمایش داده می‌شود و رزرو در همان لحظه قطعی می‌گردد؛ نیازی به تماس تلفنی یا مراجعهٔ حضوری برای گرفتن نوبت نیست. اطلاعات هر پزشک به‌صورت دوره‌ای بازبینی می‌شود تا ساعات کاری و محل پذیرش با وضعیت واقعی مطب هم‌خوان بماند. اگر ترجیح می‌دهید جست‌وجو را از یک حوزهٔ درمانی مشخص شروع کنید، صفحهٔ تخصص‌ها فهرست کامل رشته‌ها را در ${city} نشان می‌دهد و از هر رشته مستقیم به پزشکان همان حوزه می‌رسید. هزینهٔ هر خدمت نیز پیش از تأیید نهایی نمایش داده می‌شود.`,
`اینجا می‌توانید متخصصان فعال ${city} را با هم مقایسه کنید. اطلاعات هر پزشک — رشتهٔ تخصصی، سابقهٔ فعالیت، نشانی محل پذیرش و امتیاز مراجعان — به‌شکل یکسان نمایش داده می‌شود تا مقایسه منصفانه باشد. امتیازها از بیماران واقعی همان پزشک جمع‌آوری شده‌اند و نه از منابع عمومی، بنابراین تصویر دقیق‌تری از کیفیت خدمات به دست می‌دهند. برخی پزشکان بیش از یک محل پذیرش دارند و ساعات کاری هر محل جداگانه اعلام شده است؛ هنگام رزرو، محل مراجعه را خودتان انتخاب می‌کنید تا نوبت با برنامهٔ روزانه‌تان هماهنگ باشد. رزرو به‌صورت شبانه‌روزی انجام می‌شود و تأییدیه بلافاصله پیامک می‌گردد. در صورت تغییر برنامه، لغو نوبت از بخش نوبت‌های من امکان‌پذیر است و آن بازهٔ زمانی برای بیمار دیگری آزاد می‌شود.`,
`دسترسی به پزشک مناسب در ${city} با این فهرست ساده‌تر می‌شود. به‌جای جست‌وجو میان منابع پراکنده، همهٔ متخصصان شهر را با اطلاعات ساختاریافته یک‌جا می‌بینید و می‌توانید بر اساس تخصص یا نزدیکی محل مطب انتخاب کنید. رزرو نوبت به‌صورت آنلاین و شبانه‌روزی انجام می‌شود: تاریخ و ساعت خالی را انتخاب می‌کنید، تأیید می‌گیرید و پیامک تأییدیه دریافت می‌کنید. در صورت تغییر برنامه نیز لغو نوبت از حساب کاربری‌تان ممکن است. سابقهٔ نوبت‌های شما در همان حساب باقی می‌ماند و مراجعهٔ بعدی به همان پزشک سریع‌تر انجام می‌شود. مراکز درمانی و کلینیک‌های ${city} هم صفحهٔ اختصاصی خود را دارند؛ اگر ترجیح می‌دهید ابتدا مرکز درمانی را انتخاب کنید، از آن مسیر هم می‌توانید به همین پزشکان برسید.`,
];
const CLINIC_INTROS = (city) => [
`کلینیک‌ها و مراکز درمانی ${city} در این صفحه فهرست شده‌اند. برای هر مرکز، تخصص‌های ارائه‌شده، پزشکان همکار، نشانی و اطلاعات تماس نمایش داده می‌شود. اگر ترجیح می‌دهید به‌جای انتخاب پزشک، ابتدا مرکز درمانی را انتخاب کنید، این فهرست نقطهٔ شروع مناسبی است. از صفحهٔ هر کلینیک می‌توانید پزشکان همان مرکز را ببینید و مستقیم برایشان نوبت بگیرید؛ همهٔ مراحل آنلاین و بدون تماس تلفنی انجام می‌شود. صفحهٔ هر مرکز موقعیت روی نقشه و ساعات کاری را هم نشان می‌دهد تا پیش از مراجعه، مسیر و زمان را بدانید. مراکزی که به‌صورت شبانه‌روزی پذیرش دارند نیز مشخص شدهاند. برای مقایسهٔ مستقیم پزشکان به‌جای مراکز، فهرست پزشکان ${city} گزینهٔ دیگری در اختیارتان می‌گذارد.`,
`این صفحه مراکز درمانی فعال ${city} را معرفی می‌کند — از کلینیک‌های تخصصی تا درمانگاه‌های چندتخصصی. هر مرکز صفحهٔ اختصاصی خود را دارد که در آن خدمات، بیمه‌های طرف قرارداد، تخصص‌های موجود و فهرست پزشکان همکار آمده است. جست‌وجو را می‌توانید با فیلتر تخصص محدود کنید تا فقط مراکزی را ببینید که خدمت موردنظرتان را ارائه می‌دهند. رزرو نوبت از همان صفحهٔ مرکز و به‌صورت آنلاین انجام می‌شود و تأییدیه بلافاصله پیامک می‌گردد. اطلاعات تماس و نشانی هر مرکز به‌صورت دوره‌ای بازبینی می‌شود. اگر مرکزی بیش از یک شعبه دارد، هر شعبه رکورد جداگانهٔ خود را با ساعات کاری مستقل دارد تا انتخاب دقیق‌تری داشته باشید.`,
`برای یافتن مرکز درمانی مناسب در ${city}، فهرست زیر گزینه‌های موجود را با اطلاعات یکسان کنار هم می‌گذارد. تفاوت این فهرست با جست‌وجوی ساده در آن است که هر رکورد به یک صفحهٔ کامل وصل است: موقعیت روی نقشه، ساعات کاری، تخصص‌ها و پزشکانی که در آن مرکز پذیرش دارند. پس از انتخاب مرکز و پزشک، نوبت‌های خالی را می‌بینید و رزرو در همان لحظه نهایی می‌شود. هزینهٔ هر خدمت پیش از تأیید نمایش داده می‌شود تا انتخابتان با اطلاع کامل انجام شود. در صورت تغییر برنامه، لغو نوبت از بخش نوبت‌های من در حساب کاربری ممکن است و بازهٔ آزادشده بلافاصله در اختیار بیمار دیگری قرار می‌گیرد.`,
`مراکز درمانی ${city} اینجا فهرست شده‌اند. هر کارت نام مرکز، شمارهٔ تماس و نشانی را نشان می‌دهد و با یک کلیک به صفحهٔ کامل همان مرکز می‌رسید. آنجا چیزهایی هست که در کارت جا نمی‌شود: تخصص‌هایی که ارائه می‌شود، پزشکانی که پذیرش دارند، بیمه‌های طرف قرارداد و موقعیت روی نقشه. اگر خدمت مشخصی مدنظرتان است، فیلتر تخصص بالای صفحه فهرست را کوتاه می‌کند. نوبت از صفحهٔ خودِ مرکز گرفته می‌شود؛ پزشک را انتخاب می‌کنید، بازهٔ خالی را می‌زنید و تأییدیه پیامک می‌شود. مرکزی که چند شعبه دارد، هر شعبه رکورد جدا با ساعت کاری خودش دارد. فهرست صفحهبندی دارد، پس اگر شهر مراکز زیادی داشته باشد همه در یک صفحه فشرده نمی‌شوند. سابقهٔ نوبت‌هایی که می‌گیرید در حساب کاربری‌تان می‌ماند.`,
`اگر ترجیح می‌دهید اول مرکز درمانی را انتخاب کنید و بعد پزشک را، این صفحه نقطهٔ شروع است. کلینیک‌های تخصصی، درمانگاه‌های چندتخصصی و مراکز تصویربرداری ${city} کنار هم آمده‌اند. صفحهٔ هر مرکز پزشکان همکارش را با تخصص و اولین نوبت آزادشان نشان می‌دهد، پس لازم نیست جداگانه دنبال پزشک بگردید. نشانی و شمارهٔ تماس هر مرکز دوره‌ای بازبینی می‌شود. مراکزی که شبانه‌روزی پذیرش دارند جدا مشخص شده‌اند. رزرو همان‌جا انجام می‌شود و هزینهٔ خدمت پیش از تأیید نهایی نمایش داده می‌شود، پس قبل از قطعی‌شدن می‌دانید چقدر است. اگر مرکزی هنوز پزشکی ثبت نکرده باشد، صفحه‌اش اطلاعات تماس را نشان می‌دهد ولی تقویم نوبت خالی نخواهد داشت. لغو نوبت هم از بخش نوبت‌های من انجام می‌شود.`,
`فهرست زیر مراکز درمانی فعال ${city} است. تفاوتش با یک جست‌وجوی ساده این است که هر رکورد به صفحه‌ای کامل وصل است: موقعیت روی نقشه، ساعات کاری، فهرست تخصص‌ها و پزشکانی که در همان مرکز پذیرش دارند. برای مراکزی که چند تخصص را زیر یک سقف جمع کرده‌اند، می‌شود در یک مراجعه چند کار را پیش برد. بیمه‌های طرف قرارداد هم روی همان صفحه آمده. نوبت با تأیید شما قطعی می‌شود و تأییدیه پیامک می‌گردد؛ اگر برنامه‌تان عوض شد، لغو از بخش نوبت‌های من انجام می‌شود و آن بازه آزاد می‌شود. فیلتر تخصص بالای صفحه فهرست را کوتاه می‌کند و انتخاب شما در نشانی صفحه ثبت می‌شود، پس همان نما را می‌توانید دوباره باز کنید یا برای کسی بفرستید.`,
`کلینیک‌های ${city} با اطلاعات یک‌شکل کنار هم آمده‌اند تا مقایسه سخت نباشد. روی کارت هر مرکز نام و شمارهٔ تماس پیداست؛ باقی چیزها در صفحهٔ خودش است — تخصص‌ها، پزشکان، بیمه‌ها و نقشه. اگر مرکزی چند شعبه دارد، هر شعبه رکورد مستقل خودش را با ساعت کاری جداگانه دارد، پس پیش از راه‌افتادن شعبهٔ درست را انتخاب کنید. رزرو از صفحهٔ مرکز و از طریق پزشکش انجام می‌شود. تأییدیه بلافاصله پیامک می‌شود و سابقهٔ نوبت در حساب کاربری‌تان می‌ماند تا مراجعهٔ بعدی سریع‌تر باشد. هزینهٔ هر خدمت هم پیش از تأیید نهایی نمایش داده می‌شود. مراکزی که شبانه‌روزی پذیرش دارند با نشانهٔ جداگانه مشخص شده‌اند تا در شرایط اضطراری زودتر پیدایشان کنید.`,
`این صفحه مراکز درمانی ${city} را نشان می‌دهد، از کلینیک تخصصی تا درمانگاه چندتخصصی. جست‌وجو را می‌توانید با فیلتر تخصص محدود کنید تا فقط مراکزی بمانند که خدمت موردنظرتان را می‌دهند. صفحهٔ هر مرکز نشانی، شمارهٔ تماس، موقعیت روی نقشه و فهرست پزشکان همکار را دارد؛ کنار نام هر پزشک تخصص و وضعیت نوبت‌دهی‌اش هم آمده. رزرو از همان‌جا انجام می‌شود، شبانه‌روزی و بدون تماس تلفنی. اگر به‌جای مرکز دنبال یک پزشک مشخص هستید، فهرست پزشکان ${city} مسیر مستقیم‌تری است. بیمه‌های طرف قرارداد هر مرکز روی صفحهٔ خودش آمده، پس پیش از مراجعه می‌دانید پوشش دارید یا نه. اگر مرکزی چند شعبه داشته باشد، هر شعبه رکورد مستقل با ساعت کاری خودش دارد.`,
`مرکز درمانی مناسب در ${city} را از این فهرست پیدا کنید. هر مرکز صفحهٔ اختصاصی دارد و آنچه آنجا می‌بینید از رکورد خودِ مرکز می‌آید: خدمات، تخصص‌ها، بیمه‌های طرف قرارداد و پزشکانی که پذیرش دارند. موقعیت روی نقشه هم هست تا پیش از مراجعه مسیر و زمان رسیدن را بدانید. نوبت‌گیری از صفحهٔ مرکز و از طریق پزشک انجام می‌شود؛ هزینهٔ خدمت پیش از تأیید نمایش داده می‌شود. لغو نوبت از حساب کاربری ممکن است و بازهٔ لغوشده همان لحظه به فهرست نوبت‌های آزاد برمی‌گردد. برای محدود کردن فهرست می‌توانید از فیلتر تخصص استفاده کنید تا فقط مراکزی بمانند که خدمت موردنظرتان را ارائه می‌دهند. نشانی و شمارهٔ تماس هر مرکز دوره‌ای بازبینی می‌شود.`,
];
export function buildDoctorsIntro(cityName) {
return pick(DOCTOR_INTROS(cityName), hash(cityName));
}
/**
* جملهٔ آماری که به انتهای متن میچسبد.
*
* انتخاب variant با هش انجام میشود و با ۶ حالت و ۳۵ شهر، حدود ۱۵٪ جفتها همvariant
* میشوند. برای آن جفتها متن تقریباً یکسان میشد و دو صفحه دوباره شبیه هم سنجیده
* شد: بهبهان و یاسوج روی /clinics از ۶۸٫۹٪ به ۸۴٫۷٪ شباهت رفتند.
*
* عدد واقعیِ هر شهر این را میشکند، چون هیچ دو شهری آمار یکسان ندارند.
*/
const countSentence = (count, cityName, noun) =>
Number(count) > 0
? ` هم‌اکنون ${Number(count)} ${noun} در ${cityName} روی این سامانه ثبت شده‌اند.`
: "";
export function buildClinicsIntro(cityName) {
return pick(CLINIC_INTROS(cityName), hash(cityName) >>> 2);
export function buildClinicsIntro(cityName, clinicCount = 0) {
return (
pick(CLINIC_INTROS(cityName), hash(cityName) >>> 2) +
countSentence(clinicCount, cityName, "مرکز درمانی")
);
}
+21
View File
@@ -0,0 +1,21 @@
/**
* segment مسیر را به شکل decodeشده برمیگرداند.
*
* Next در `generateMetadata` مقدار decodeشده میدهد ولی در بدنهٔ همان صفحه مقدار
* خام؛ روی اسلاگهای فارسی این یعنی متادیتا درست ساخته میشود و بلافاصله بعدش
* صفحه ۴۰۴ میدهد. با build واقعی سنجیده شد:
*
* [meta] "جراح-گوارش" found: true
* [body] "%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF..." found: false
*
* decode شکستخورده (درصدِ ناقص در ورودی) نباید صفحه را بشکند؛ همان مقدار خام
* برمیگردد تا نتیجهاش ۴۰۴ طبیعی باشد نه ۵۰۰.
*/
export function decodeRouteParam(value) {
if (typeof value !== "string" || !value.includes("%")) return value;
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { decodeRouteParam } from "@/lib/routeParams";
describe("decodeRouteParam", () => {
it("اسلاگ درصدی را به فارسی برمی‌گرداند", () => {
expect(
decodeRouteParam("%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4")
).toBe("جراح-گوارش");
});
it("فارسی خام دست‌نخورده می‌ماند", () => {
expect(decodeRouteParam("جراح-گوارش")).toBe("جراح-گوارش");
});
it("اسلاگ لاتین دست‌نخورده می‌ماند", () => {
expect(decodeRouteParam("gastroenterology")).toBe("gastroenterology");
});
it("درصد ناقص throw نمی‌کند و ورودی را برمی‌گرداند", () => {
// نتیجه‌اش باید ۴۰۴ طبیعی باشد، نه ۵۰۰.
expect(decodeRouteParam("%D8%")).toBe("%D8%");
expect(decodeRouteParam("100%-natural")).toBe("100%-natural");
});
it("ورودی غیر رشته‌ای همان‌طور برمی‌گردد", () => {
expect(decodeRouteParam(undefined)).toBeUndefined();
expect(decodeRouteParam(null)).toBeNull();
});
});
+33 -22
View File
@@ -22,34 +22,45 @@ function hash(...parts) {
// می‌شدند و ایندکس منفی به undefined می‌رسید.
const pick = (variants, seed) => variants[(seed >>> 0) % variants.length];
// متن‌ها عمداً ریتم یکنواخت ندارند: بعضی جمله‌ها کوتاه و خبری‌اند و بعضی بلندتر.
// حرف‌های ربطِ قالبی («به همین دلیل»، «در نهایت»، «لازم به ذکر است») و ادعاهای
// تبلیغاتیِ بی‌پشتوانه کنار گذاشته شده‌اند؛ هر جمله به چیزی اشاره می‌کند که واقعاً
// روی همین صفحه هست: فیلدهای پروفایل، تقویم نوبت، محل‌های پذیرش، هزینه و لغو.
function openingVariants(specialty, city) {
return [
`اگر به دنبال متخصص ${specialty} در ${city} هستید، این صفحه فهرست کاملی از پزشکان فعال این حوزه را در اختیار شما می‌گذارد. هر پروفایل شامل تخصص‌ها، سوابق، محل مطب و امتیاز واقعی بیماران است تا بتوانید پیش از رزرو نوبت، انتخاب آگاهانه‌تری داشته باشید.`,
`انتخاب پزشک ${specialty} در ${city} وقتی ساده‌تر می‌شود که همهٔ گزینه‌ها را یک‌جا و با اطلاعات یکسان ببینید. در این صفحه، متخصصان ${specialty} شهر ${city} همراه با نشانی مطب، ساعات پذیرش و بازخورد مراجعان گردآوری شده‌اند.`,
`دسترسی به متخصص ${specialty} در ${city} دیگر نیازی به تماس‌های پی‌درپی و مراجعهٔ حضوری برای گرفتن نوبت ندارد. فهرست زیر پزشکان این تخصص را در ${city} نشان می‌دهد؛ از هر پروفایل می‌توانید مستقیم و آنلاین نوبت بگیرید.`,
`این صفحه به معرفی پزشکان ${specialty} در ${city} اختصاص دارد. هدف، کوتاه‌کردن مسیر میان «جست‌وجوی یک متخصص خوب» و «داشتن نوبت قطعی» است؛ بنابراین اطلاعات هر پزشک به‌شکلی چیده شده که مقایسه میان گزینه‌ها سریع انجام شود.`,
`پزشکان ${specialty} ${city} اینجا فهرست شده‌اند. هر کارت نام پزشک، درجهٔ تخصصی، محل پذیرش و اولین نوبت آزادش را نشان می‌دهد. اگر دنبال پزشک مشخصی هستید، کادر جست‌وجوی بالای صفحه نام پزشک و نام تخصص را با هم می‌گیرد. فیلترها هم هست: جنسیت پزشک، درجهٔ علمی، و اینکه فقط پزشکان دارای نوبت آزاد را ببینید.`,
`این فهرست متخصصان ${specialty} را در ${city} نشان می‌دهد. ترتیب نمایش تصادفی نیست؛ پزشکانی که هم‌اکنون نوبت خالی دارند بالاتر می‌آیند و بعد از آن امتیاز بیماران تعیین‌کننده است. روی هر کارت، تخصص و محل مطب پیداست، پس لازم نیست برای مقایسهٔ دو پزشک دو صفحه باز کنید. صفحه‌بندی هم دارد، پس فهرست بلند در یک صفحه فشرده نمی‌شود.`,
`برای پیدا کردن متخصص ${specialty} در ${city} از همین صفحه شروع کنید. کارت هر پزشک تخصص، محل پذیرش، امتیاز مراجعان و وضعیت نوبت‌دهی را کنار هم می‌گذارد. پزشکی که نوبت آزاد ندارد هم نمایش داده می‌شود، با برچسب مشخص، تا بدانید گزینه هست ولی فعلاً در دسترس نیست. فیلترها روی نشانی صفحه ثبت می‌شوند، پس لینک همین نما را می‌توانید نگه دارید.`,
`فهرست زیر پزشکان ${specialty} شهر ${city} است. آنچه در هر کارت می‌بینید از پروفایل خودِ پزشک می‌آید: درجهٔ تخصصی، محل یا محل‌های پذیرش، و امتیازی که بیماران قبلی ثبت کرده‌اند. برای دیدن ساعات کاری و تقویم نوبت، وارد پروفایل شوید. اگر ${specialty} زیرشاخه دارد، نوار بالای صفحه آن‌ها را نشان می‌دهد و می‌توانید فهرست را به یکی از زیرشاخه‌ها محدود کنید یا روی کل گروه بمانید.`,
`متخصص ${specialty} در ${city} کم نیست و همین انتخاب را سخت می‌کند. این صفحه اطلاعات همه را به یک شکل نشان می‌دهد تا مقایسه ممکن باشد: تخصص، محل مطب، امتیاز، و اینکه نزدیک‌ترین نوبت خالی کِی است. جست‌وجو با نام تخصص هم کار می‌کند، نه فقط نام پزشک؛ پزشکی که چند تخصص دارد با هر کدامشان پیدا می‌شود.`,
`اگر تازه دنبال پزشک ${specialty} در ${city} می‌گردید، از فهرست پایین شروع کنید. هر کارت خلاصه‌ای از پروفایل است و با یک کلیک به صفحهٔ کامل پزشک می‌رسید. آنجا محل‌های پذیرش، ساعات کاری هر محل و تقویم نوبت‌های خالی را می‌بینید. پزشکی که فعلاً نوبت‌دهی‌اش خاموش است هم در فهرست می‌ماند، فقط با برچسب مشخص.`,
];
}
function bodyVariants(specialty, city, doctorCount) {
const countPhrase =
doctorCount > 0
? `در حال حاضر ${doctorCount} پزشک ${specialty} در ${city} در این سامانه ثبت شده‌اند`
: `فهرست پزشکان ${specialty} در ${city} به‌طور مداوم به‌روزرسانی می‌شود`;
? `${doctorCount} پزشک ${specialty} در ${city} روی این سامانه پروفایل دارند`
: `فهرست پزشکان ${specialty} در ${city} در حال تکمیل است`;
return [
`${countPhrase}. برای هر پزشک، مواردی مانند مدرک و درجهٔ تخصصی، کد نظام پزشکی، خدمات قابل ارائه و موقعیت مکانی مطب نمایش داده می‌شود. امتیازها و نظرها از مراجعان واقعی همان پزشک جمع‌آوری شده‌اند، نه از منابع عمومی؛ به همین دلیل تصویر دقیق‌تری از کیفیت خدمات به دست می‌دهند.`,
`${countPhrase} و اطلاعات آن‌ها به‌صورت دوره‌ای بازبینی می‌شود. تفاوت این فهرست با جست‌وجوی ساده در آن است که هر رکورد به یک پروفایل کامل وصل است: خدمات، محل‌های پذیرش، ساعات کاری هر محل و امکان رزرو مستقیم. نتیجه آنکه مقایسهٔ چند پزشک در چند دقیقه ممکن می‌شود.`,
`${countPhrase}. برخی از این پزشکان بیش از یک محل پذیرش دارند — مطب شخصی و یک یا چند کلینیک — و ساعات کاری هر محل جداگانه اعلام شده است. هنگام رزرو، محل مراجعه را خودتان انتخاب می‌کنید تا نوبتی که می‌گیرید با برنامهٔ رفت‌وآمد شما هماهنگ باشد.`,
`${countPhrase}. کد نظام پزشکی هر پزشک روی پروفایلش درج شده و می‌توانید آن را جداگانه راستی‌آزمایی کنید. امتیازها از بیمارانی می‌آید که از همین سامانه نوبت گرفته‌اند، نه از نظرسنجی عمومی. پزشکی که هنوز نظری برایش ثبت نشده، امتیاز خالی نشان می‌دهد؛ عدد ساختگی جایگزینش نمی‌شود.`,
`${countPhrase} و اطلاعاتشان دوره‌ای بازبینی می‌شود. بخشی از این پزشکان بیش از یک محل پذیرش دارند: مطب شخصی و یک یا چند کلینیک. ساعات کاری هر محل جداست و هنگام رزرو خودتان انتخاب می‌کنید کجا مراجعه کنید. این را در تقویم نوبت هم می‌بینید، چون بازه‌های خالی هر محل فرق دارد.`,
`${countPhrase}. خدماتی که هر پزشک ارائه می‌دهد روی پروفایلش فهرست شده — از ویزیت ساده تا اقدام‌های تخصصی‌تر. هزینهٔ هر خدمت پیش از تأیید نهایی نوبت نمایش داده می‌شود، پس لازم نیست برای پرسیدن قیمت تماس بگیرید. اگر پزشک در کلینیک پذیرش داشته باشد، بیمه‌های طرف قرارداد آن مرکز روی صفحهٔ خودِ کلینیک آمده است.`,
`${countPhrase}. زیرتخصص‌ها هم جدا مشخص شده‌اند. اگر روی صفحهٔ یک گروه تخصصی هستید، پزشکانی که فقط در زیرشاخه‌های آن کار می‌کنند هم در همین فهرست می‌آیند؛ برای محدود کردن، از فهرست زیرتخصص‌های بالای صفحه استفاده کنید. پزشکی که چند زیرتخصص دارد، همه را روی پروفایلش نشان می‌دهد نه فقط یکی را.`,
`${countPhrase}. موقعیت مطب هر پزشک روی نقشه هم قابل دیدن است. اگر چند گزینه شبیه هم بودند، معمولاً همین فاصله و ساعت کاری تعیین‌کننده می‌شود. برای پزشکانی که در کلینیک پذیرش دارند، صفحهٔ خودِ کلینیک هم نشانی و شمارهٔ تماس را نشان می‌دهد و مسیر رسیدن روشن‌تر است.`,
];
}
function closingVariants(specialty, city) {
return [
`پس از انتخاب پزشک، تقویم نوبت‌های خالی نمایش داده می‌شود و رزرو در همان لحظه قطعی می‌گردد؛ پیامک تأیید نیز برایتان ارسال خواهد شد. اگر برنامه‌تان تغییر کرد، لغو نوبت از بخش نوبت‌های من در حساب کاربری ممکن است و آن بازهٔ زمانی بلافاصله برای بیمار دیگری آزاد می‌شود. اگر تخصص موردنظرتان ${specialty} نیست، از فهرست تخصص‌های ${city} می‌توانید حوزهٔ درمانی دیگری را دنبال کنید و همان مسیر رزرو را طی کنید.`,
`نوبت‌گیری در همین صفحه انجام می‌شود: تاریخ و ساعت خالی را می‌بینید، انتخاب می‌کنید و تأیید نهایی را دریافت می‌کنید. هزینهٔ هر خدمت پیش از تأیید نمایش داده می‌شود تا انتخابتان با اطلاع کامل انجام شود. برای حوزه‌های درمانی دیگر در ${city}، صفحهٔ تخصص‌ها فهرست کامل رشته‌ها را در اختیارتان می‌گذارد و از هر رشته می‌توانید مستقیم به پزشکان همان حوزه برسید.`,
`تمام مراحل — از مقایسهٔ پزشکان ${specialty} تا دریافت تأییدیهٔ نوبت — آنلاین و بدون مراجعهٔ حضوری طی می‌شود؛ نیازی به تماس تلفنی در ساعات اداری نیست و رزرو شبانه‌روزی انجام می‌گیرد. کلینیک‌ها و مراکز درمانی ${city} نیز صفحهٔ اختصاصی خود را دارند و اگر ترجیح می‌دهید ابتدا مرکز درمانی را انتخاب کنید، از همان مسیر قابل رزرو هستند.`,
`برای رزرو کافی است روی پروفایل پزشک بزنید و از میان نوبت‌های خالی یکی را انتخاب کنید؛ تأییدیه بلافاصله صادر و پیامک می‌شود. سابقهٔ نوبت‌های شما در حساب کاربری باقی می‌ماند و مراجعهٔ بعدی به همان پزشک سریع‌تر انجام می‌شود. اگر ترجیح می‌دهید بر اساس مرکز درمانی جست‌وجو کنید، فهرست کلینیک‌های ${city} گزینهٔ مناسبی برای شروع است.`,
`رزرو داخل پروفایل انجام می‌شود: تقویم نوبت‌های خالی را می‌بینید، یک بازه را انتخاب می‌کنید و تأییدیه پیامک می‌شود. اگر برنامه‌تان به هم خورد، لغو از بخش نوبت‌های من ممکن است و آن بازه بلافاصله برای بیمار دیگری آزاد می‌شود. سابقهٔ نوبت‌ها هم در همان حساب می‌ماند.`,
`نوبت‌گیری شبانه‌روزی است. لازم نیست منتظر ساعت اداری بمانید یا با منشی مطب تماس بگیرید. بعد از تأیید، نوبت در حساب کاربری‌تان ثبت می‌ماند و دفعهٔ بعد که به همان پزشک مراجعه کنید، کارتان کوتاه‌تر است. اگر پزشک چند محل پذیرش دارد، محلی که انتخاب کرده‌اید هم روی همان نوبت ثبت می‌شود.`,
`اگر تخصص موردنظرتان ${specialty} نبود، فهرست تخصص‌های ${city} رشته‌های دیگر را نشان می‌دهد و مسیر رزرو همه‌جا یکی است. برای شروع از سمت مرکز درمانی به‌جای پزشک هم صفحهٔ کلینیک‌های ${city} هست؛ آنجا پزشکان هر مرکز کنار هم آمده‌اند و از همان صفحه هم می‌شود نوبت گرفت.`,
`پیش از تأیید، هزینهٔ خدمتی که انتخاب کرده‌اید نمایش داده می‌شود. تأییدیه بلافاصله پیامک می‌گردد و شمارهٔ نوبت در حساب کاربری می‌ماند. لغو هم از همان‌جا انجام می‌شود. اگر پزشک برای خدمت خاصی مدت‌زمان متفاوتی تعریف کرده باشد، طول نوبت هم پیش از تأیید مشخص است.`,
`پزشکانی که در کلینیک پذیرش دارند از صفحهٔ همان کلینیک هم قابل رزروند. اگر ترجیح می‌دهید اول مرکز درمانی را انتخاب کنید و بعد پزشک را، فهرست کلینیک‌های ${city} نقطهٔ شروع دیگری است. نتیجهٔ رزرو در هر دو مسیر یکی است و تأییدیه به همان شکل پیامک می‌شود.`,
`یک نکتهٔ کاربردی: اگر پزشک بیش از یک محل پذیرش دارد، پیش از انتخاب بازه محل را تعیین کنید؛ نوبت‌های خالی هر محل جدا محاسبه می‌شود. تأییدیه پیامک می‌شود و لغو تا پیش از زمان نوبت امکان دارد. بازه‌ای که لغو کنید همان لحظه در فهرست نوبت‌های آزاد برمی‌گردد.`,
];
}
@@ -69,13 +80,13 @@ export function buildDoctorFaq(doctorName, specialtyName, cityName) {
return [
{
question: `چگونه از ${who} نوبت بگیرم؟`,
answer: `از همین صفحه، محل مراجعه و سپس تاریخ و ساعت خالی موردنظرتان را انتخاب کنید. رزرو در همان لحظه قطعی می‌شود و تأییدیه برایتان پیامک خواهد شد.`,
answer: `اول محل مراجعه را از همین صفحه انتخاب کنید، بعد از تقویم، تاریخ و ساعتی که خالی است. رزرو با تأیید شما قطعی می‌شود و پیامک تأییدیه می‌آید. نیازی به تماس تلفنی نیست.`,
},
...(specialtyName
? [
{
question: `تخصص ${who} چیست؟`,
answer: `${who} در حوزهٔ ${specialtyName} فعالیت می‌کند. فهرست کامل خدمات قابل ارائه در همین صفحه آمده است.`,
answer: `${who} در حوزهٔ ${specialtyName} کار می‌کند. اگر تخصص دیگری هم داشته باشد، بالای همین صفحه کنار نامش آمده. خدماتی که ارائه می‌دهد پایین‌تر فهرست شده است.`,
},
]
: []),
@@ -83,13 +94,13 @@ export function buildDoctorFaq(doctorName, specialtyName, cityName) {
? [
{
question: `مطب ${who} در کدام شهر است؟`,
answer: `محل یا محل‌های پذیرش ${who} در ${cityName} به همراه نشانی دقیق و ساعات کاری هر محل، در بخش موقعیت مکانی همین صفحه نمایش داده شده است.`,
answer: `در ${cityName}. نشانی دقیق و ساعات کاری در بخش موقعیت مکانی همین صفحه آمده و اگر بیش از یک محل پذیرش داشته باشد، هر کدام جداگانه با ساعت کاری خودش فهرست شده است.`,
},
]
: []),
{
question: `امکان لغو نوبت وجود دارد؟`,
answer: `بله. از بخش نوبت‌های من در حساب کاربری‌تان می‌توانید نوبت را لغو کنید؛ لغو زودهنگام آن بازهٔ زمانی را برای بیمار دیگری آزاد می‌کند.`,
answer: `بله، از بخش نوبت‌های من در حساب کاربری. هرچه زودتر لغو کنید آن بازه سریع‌تر آزاد می‌شود و بیمار دیگری می‌تواند رزروش کند.`,
},
];
}
@@ -111,22 +122,22 @@ export function buildSpecialtyFaq(
return [
{
question: `چطور می‌توان از متخصص ${specialtyName} در ${place} نوبت گرفت؟`,
answer: `از فهرست همین صفحه پزشک موردنظرتان را انتخاب کنید، وارد پروفایل او شوید و از تقویم نوبت‌های خالی، تاریخ و ساعت دلخواه را ثبت کنید. رزرو در همان لحظه قطعی می‌شود و تأییدیه برایتان پیامک می‌شود.`,
answer: `از فهرست همین صفحه پزشک را انتخاب کنید و وارد پروفایلش شوید. آنجا تقویم نوبت‌های خالی را می‌بینید؛ یک بازه را بزنید و تأیید کنید. تأییدیه همان لحظه پیامک می‌شود. اگر پزشک چند محل پذیرش دارد، اول محل را انتخاب کنید چون نوبت‌های خالی هر محل جداست.`,
},
{
question: `چند پزشک ${specialtyName} در ${place} در دسترس است؟`,
answer:
doctorCount > 0
? `در حال حاضر ${doctorCount} پزشک ${specialtyName} در ${place} در این سامانه ثبت شده‌اند. این عدد با افزوده‌شدن پزشکان جدید به‌روزرسانی می‌شود.`
: `فهرست پزشکان ${specialtyName} در ${place} در حال تکمیل است و به‌مرور پزشکان جدید به آن افزوده می‌شوند.`,
? `${doctorCount} پزشک ${specialtyName} در ${place} روی این سامانه پروفایل دارند. همهٔ آن‌ها لزوماً همیشه نوبت آزاد ندارند؛ کارت هر پزشک وضعیت نوبت‌دهی‌اش را نشان می‌دهد.`
: `هنوز پزشکی با تخصص ${specialtyName} در ${place} روی سامانه ثبت نشده. می‌توانید از فهرست تخصص‌ها حوزهٔ نزدیک‌تری را ببینید یا شهر دیگری را امتحان کنید.`,
},
{
question: `هزینهٔ ویزیت متخصص ${specialtyName} چقدر است؟`,
answer: `هزینهٔ ویزیت به نوع خدمت، درجهٔ تخصصی پزشک و پوشش بیمه بستگی دارد. مبلغ هر خدمت پیش از تأیید نهایی نوبت، در همان صفحهٔ رزرو نمایش داده می‌شود.`,
answer: `عدد ثابتی ندارد و به خدمتی که انتخاب می‌کنید، درجهٔ تخصصی پزشک و بیمه‌ای که دارید بستگی دارد. مبلغ پیش از تأیید نهایی روی همان صفحهٔ رزرو نمایش داده می‌شود، پس قبل از قطعی‌شدن نوبت می‌دانید چقدر است.`,
},
{
question: `اگر نتوانم در زمان نوبت مراجعه کنم چه کنم؟`,
answer: `از بخش نوبت‌های من در حساب کاربری‌تان می‌توانید نوبت را لغو کنید. لغو زودهنگام باعث می‌شود آن بازهٔ زمانی برای بیمار دیگری آزاد شود.`,
answer: `از بخش نوبت‌های من در حساب کاربری‌تان نوبت را لغو کنید. هرچه زودتر لغو کنید، آن بازه سریع‌تر در فهرست نوبت‌های آزاد برمی‌گردد و بیمار دیگری می‌تواند رزروش کند.`,
},
];
}
+9 -8
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildSpecialtyFaq, buildSpecialtyIntro } from "@/lib/specialtyContent";
import { buildClinicsIntro, buildDoctorsIntro } from "@/lib/listingIntro";
import { buildClinicsIntro } from "@/lib/listingIntro";
const words = (text) => text.trim().split(/\s+/).length;
@@ -28,18 +28,19 @@ describe("متن مقدمهٔ تخصص (M2)", () => {
});
});
describe("متن مقدمهٔ صفحات لیست (M2)", () => {
it("بین ۱۵۰ تا ۳۰۰ کلمه", () => {
// متن /doctors حذف شد — آن صفحه فقط کارت پزشک‌ها را نشان می‌دهد، پس
// buildDoctorsIntro و مخزن متنش هم برداشته شدند. اینجا فقط /clinics می‌ماند.
describe("متن مقدمهٔ صفحهٔ کلینیک‌ها (M2)", () => {
it("بین ۱۰۰ تا ۳۰۰ کلمه", () => {
for (const city of CITIES) {
for (const text of [buildDoctorsIntro(city), buildClinicsIntro(city)]) {
expect(words(text), `${city} = ${words(text)}`).toBeGreaterThanOrEqual(100);
expect(words(text), `${city} = ${words(text)}`).toBeLessThanOrEqual(300);
}
const text = buildClinicsIntro(city);
expect(words(text), `${city} = ${words(text)}`).toBeGreaterThanOrEqual(100);
expect(words(text), `${city} = ${words(text)}`).toBeLessThanOrEqual(300);
}
});
it("ساختار متن بین شهرها متفاوت است", () => {
const texts = CITIES.map(buildDoctorsIntro);
const texts = CITIES.map((city) => buildClinicsIntro(city));
expect(new Set(texts).size).toBe(CITIES.length);
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* قاعدهٔ مشترک نمایش تخصصهای پزشک منبع واحد کارت و پوستر.
*
* پزشک میتواند چند تخصص داشته باشد و معمولاً هم دارد: ذخیرهٔ یک زیرتخصص در
* بکاند والدهایش را هم مینشاند، پس «جراح گوارش» عملاً یعنی «جراحی عمومی» +
* زیرشاخه. چاپ همهٔ نامها پشتسرهم، در موبایل ارتفاع کارت را باد میکند و در
* پوستر از کادر ثابت بیرون میزند.
*
* نمایش در دو جا متفاوت است کارت `+N` کلیکشدنی دارد و پوستر ندارد اما
* «کدام تخصص اصلی است» یک قاعده بیشتر نیست و اینجا میماند.
*/
const SEPARATOR = " · ";
/**
* تخصص «اصلی» و بقیه.
*
* ریشه (بدون `parent_id`) اصلی است: عنوانی است که بیمار میشناسد و چون هنگام
* ذخیره خودکار اضافه میشود تقریباً همیشه وجود دارد. اگر پزشکی فقط زیرتخصص داشت،
* اولین آیتم آرایه.
*
* @param {Array<{name?: string, parent_id?: string|number|null}>} list
* @returns {{primary: object|null, rest: Array<object>}}
*/
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 == null) ?? items[0];
return { primary, rest: items.filter((s) => s !== primary) };
}
/**
* زیرتخصصها بهشکل یک خط متنی، بریده روی مرز کلمه با بودجهٔ کاراکتر.
*
* پوستر کادر ثابت ۱۰۸۰×۱۳۵۰ با `overflow-hidden` دارد؛ هر ردیف اضافه بخشهای
* پایین را بیرون میاندازد. برش بر اساس طول متن است نه تعداد ثابت، چون
* «جراحی لاپاراسکوپی» و «قلب» یکاندازه جا نمیگیرند.
*
* @param {Array<{name?: string}>} rest
* @param {number} budget حداکثر کاراکترِ خط
* @returns {{text: string, hidden: number}}
*/
export function posterSpecialtyLine(rest, budget = 90) {
const names = (rest ?? []).filter((s) => s?.name).map((s) => s.name);
const shown = [];
let used = 0;
for (const name of names) {
const cost = name.length + (shown.length ? SEPARATOR.length : 0);
if (shown.length && used + cost > budget) break;
shown.push(name);
used += cost;
}
return { text: shown.join(SEPARATOR), hidden: names.length - shown.length };
}
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay";
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("splitSpecialties", () => {
it("ریشه اصلی است، حتی اگر اول آرایه نباشد", () => {
const { primary, rest } = splitSpecialties([gi, root, thyroid]);
expect(primary).toBe(root);
expect(rest).toEqual([gi, thyroid]);
});
it("بدون ریشه، اولین آیتم اصلی است", () => {
const { primary, rest } = splitSpecialties([gi, thyroid]);
expect(primary).toBe(gi);
expect(rest).toEqual([thyroid]);
});
it("تک‌تخصص، بقیه خالی", () => {
const { primary, rest } = splitSpecialties([root]);
expect(primary).toBe(root);
expect(rest).toEqual([]);
});
it("آرایهٔ خالی و ورودی تهی → primary تهی", () => {
expect(splitSpecialties([])).toEqual({ primary: null, rest: [] });
expect(splitSpecialties(null)).toEqual({ primary: null, rest: [] });
expect(splitSpecialties(undefined)).toEqual({ primary: null, rest: [] });
});
it("آیتم بدون نام حذف می‌شود", () => {
const { primary, rest } = splitSpecialties([{ id: "1" }, root, gi]);
expect(primary).toBe(root);
expect(rest).toEqual([gi]);
});
it("parent_id تهیِ رشته‌ای هم ریشه شمرده می‌شود", () => {
// بک‌اند parent_id را رشته می‌دهد؛ برابریِ سست عمدی است تا null و undefined
// هر دو ریشه باشند و 0 نباشد.
const { primary } = splitSpecialties([
{ id: "9", name: "زیرشاخه", parent_id: "13" },
{ id: "13", name: "ریشه", parent_id: undefined },
]);
expect(primary.name).toBe("ریشه");
});
it("دو ریشهٔ متفاوت → اولی اصلی، دومی در بقیه", () => {
const internal = { id: "2", name: "داخلی", parent_id: null };
const { primary, rest } = splitSpecialties([root, internal, gi]);
expect(primary).toBe(root);
expect(rest).toEqual([internal, gi]);
});
});
describe("posterSpecialtyLine", () => {
it("نام‌ها را با جداکنندهٔ نقطه به هم می‌چسباند", () => {
const { text, hidden } = posterSpecialtyLine([gi, thyroid], 90);
expect(text).toBe("جراح گوارش · جراح تیروئید");
expect(hidden).toBe(0);
});
it("از بودجه که گذشت، بقیه را می‌شمارد نه می‌برد", () => {
const { text, hidden } = posterSpecialtyLine([gi, thyroid], 12);
expect(text).toBe("جراح گوارش");
expect(hidden).toBe(1);
});
it("برش روی مرز کلمه است، نه وسط نام", () => {
const { text } = posterSpecialtyLine([gi, thyroid], 15);
expect(text.endsWith("…")).toBe(false);
expect(text).toBe("جراح گوارش");
});
it("یک نام بلندتر از کل بودجه، بریده نمی‌شود", () => {
// خط خالی با «و ۱ تخصص دیگر» بدتر از یک نام کمی بلند است.
const long = { name: "جراحی لاپاراسکوپی پیشرفتهٔ کبد و مجاری صفراوی" };
const { text, hidden } = posterSpecialtyLine([long], 10);
expect(text).toBe(long.name);
expect(hidden).toBe(0);
});
it("آرایهٔ خالی و ورودی تهی → متن تهی و صفر پنهان", () => {
expect(posterSpecialtyLine([])).toEqual({ text: "", hidden: 0 });
expect(posterSpecialtyLine(null)).toEqual({ text: "", hidden: 0 });
});
it("آیتم بدون نام نه نمایش داده می‌شود نه شمرده", () => {
const { text, hidden } = posterSpecialtyLine([{ id: "1" }, gi], 90);
expect(text).toBe("جراح گوارش");
expect(hidden).toBe(0);
});
it("شش تخصصِ نمونهٔ واقعی از بودجهٔ پیش‌فرض نمی‌گذرد", () => {
const real = [
{ name: "جراحی پلاستیک و زیبایی" },
{ name: "جراحی لاپاراسکوپی" },
{ name: "جراح تیروئید" },
{ name: "جراح گوارش" },
{ name: "جراحی سرطانها" },
];
const { text, hidden } = posterSpecialtyLine(real);
expect(text.length).toBeLessThanOrEqual(90);
expect(hidden).toBe(real.length - text.split(" · ").length);
});
});
+24
View File
@@ -25,3 +25,27 @@ export function isCategorySpecialty(specialty) {
export function isIndexableSpecialty(specialty) {
return Boolean(specialty) && !isCategorySpecialty(specialty);
}
/** زیرشاخه‌های فعالِ یک تخصص، به ترتیب weight سپس نام. */
export function childSpecialties(specialty) {
if (!specialty) return [];
return specialtiesData
.filter(
(item) =>
item?.status === 1 &&
String(item.parent_id ?? "") === String(specialty.id)
)
.sort((a, b) => (a.weight ?? 0) - (b.weight ?? 0) || a.name.localeCompare(b.name, "fa"));
}
/** والدِ فعالِ یک زیرتخصص، یا null اگر ریشه باشد. */
export function parentSpecialty(specialty) {
if (!specialty?.parent_id) return null;
return (
specialtiesData.find(
(item) => item?.status === 1 && String(item.id) === String(specialty.parent_id)
) ?? null
);
}
+70
View File
@@ -0,0 +1,70 @@
// متن شهریِ صفحات «درباره ما» و «تماس با ما».
//
// این دو صفحه تا پیش از این به دامنهٔ اصلی canonical می‌شدند، چون متنشان روی همهٔ
// دامنه‌ها یکی بود. حالا self-canonical‌اند و باید واقعاً متفاوت باشند، وگرنه همان
// duplicate با شکل تازه برمی‌گردد.
//
// داده‌ای که واقعاً بین شهرها فرق می‌کند: نام شهر، نام استان، و شعار شهر.
// تلفن و ایمیل روی هر ۳۵ شهر یکسان است، پس سیگنال تمایز نیستند.
function hash(value) {
let out = 0;
for (let i = 0; i < value.length; i++) out = (out * 31 + value.charCodeAt(i)) >>> 0;
return out;
}
const pick = (variants, seed) => variants[(seed >>> 0) % variants.length];
const ABOUT_INTROS = (city, province, brand) => [
`${brand} سرویس نوبت‌دهی آنلاین شهر ${city} است. پزشکان عمومی و متخصصی که در ${city} پذیرش دارند اینجا پروفایل دارند و می‌توانید بدون تماس تلفنی نوبتشان را بگیرید. پوشش ما ${province} است و فهرست پزشکان همین استان به‌مرور کامل‌تر می‌شود.`,
`اگر ساکن ${city} هستید، ${brand} کوتاه‌ترین مسیر تا نوبت پزشک است. جست‌وجو بر اساس تخصص یا نام پزشک انجام می‌شود و نتیجه فقط پزشکان همین شهر و اطراف آن در ${province} را نشان می‌دهد، نه فهرستی سراسری که به کارتان نمی‌آید.`,
`${brand} بخشی از شبکه‌ای است که برای هر شهر یک سایت مستقل دارد. این نسخه مخصوص ${city} است: پزشکان، مراکز درمانی و برنامهٔ نوبت‌دهی همه از ${city} و ${province} می‌آیند. همین باعث می‌شود نتیجهٔ جست‌وجو کوتاه و مرتبط بماند.`,
`هدف ${brand} این است که مراجعهٔ حضوری برای گرفتن نوبت در ${city} حذف شود. برنامهٔ کاری پزشک، بازه‌های خالی و هزینهٔ خدمت پیش از تأیید نهایی نمایش داده می‌شود. دامنهٔ کاری ما ${province} است و مراکز درمانی همین استان به سامانه اضافه می‌شوند.`,
`${brand} برای مردم ${city} ساخته شده است. به‌جای گشتن میان شماره‌های پراکنده، اینجا پزشک را بر اساس تخصص پیدا می‌کنید، نوبت آزادش را می‌بینید و همان لحظه رزرو می‌کنید. پزشکان ${province} به‌تدریج به همین فهرست اضافه می‌شوند.`,
`این صفحه دربارهٔ ${brand} است، سامانهٔ نوبت‌دهی آنلاین ${city}. کار ما ساده است: نشان دادن اینکه کدام پزشک ${city} چه زمانی نوبت آزاد دارد و ثبت نوبت بدون تماس تلفنی. تمرکز جغرافیایی ما روی ${province} است.`,
];
const CONTACT_INTROS = (city, province, brand) => [
`برای ارتباط با ${brand} از راه‌های زیر استفاده کنید. پاسخ‌گویی مربوط به کاربران ${city} و ${province} از همین کانال‌ها انجام می‌شود: پیگیری نوبت، لغو نوبت و مشکلات ورود به حساب.`,
`اگر در ${city} با نوبتی که رزرو کرده‌اید مشکلی داشتید، از همین صفحه با ${brand} تماس بگیرید. برای هماهنگی مستقیم با مطب، شمارهٔ خود پزشک روی صفحهٔ پروفایلش در ${province} آمده است.`,
`${brand} پشتیبانی کاربران ${city} را از راه‌های زیر انجام می‌دهد. برای درخواست افزودن مطب یا مرکز درمانی در ${province} هم از همین کانال‌ها می‌توانید اقدام کنید.`,
`این‌ها راه‌های تماس با ${brand} است. سؤال‌های مربوط به رزرو، لغو و بازگشت وجه کاربران ${city} از همین‌جا پیگیری می‌شود و برای پزشکان ${province} هم مسیر جداگانه‌ای برای ثبت‌نام وجود دارد.`,
`ارتباط با ${brand} برای کاربران ${city} از طریق تلفن، واتساپ و ایمیل ممکن است. اگر پزشک یا مسئول مرکز درمانی در ${province} هستید و می‌خواهید به سامانه اضافه شوید، از همین صفحه هماهنگ کنید.`,
`اگر سؤالی دربارهٔ نوبت‌دهی آنلاین در ${city} دارید، تیم ${brand} از راه‌های زیر در دسترس است. برای موضوع‌های مربوط به خودِ مطب — تغییر ساعت ویزیت یا آدرس — تماس با مطب در ${province} سریع‌تر جواب می‌دهد.`,
];
/**
* جملهٔ پایانی که به متن میچسبد.
*
* با ۶ variant و ۳۵ شهر، جفتهای همvariant اجتنابناپذیرند سنجیده شد: بهبهان و
* یاسوج روی هر دو صفحه یک variant گرفتند و تفاوتشان فقط جایگذاری نام شهر بود.
* شعار شهر این را میشکند چون در city.json برای هر ۳۵ شهر یکتاست.
*/
const sloganSentence = (slogan) =>
slogan ? ` شعار ما همین است: ${slogan}.` : "";
/**
* seed از نام شهر میآید تا متن هر شهر ثابت بماند ولی بین شهرها واگرا شود.
* دو مجموعه seed متفاوت میگیرند، وگرنه یک شهر در هر دو صفحه ساختار یکسان میگرفت.
*/
export function buildAboutIntro(cityName, provinceName, siteName, slogan = "") {
if (!cityName) return "";
const province = provinceName || cityName;
const brand = siteName || `${cityName} نوبت`;
return (
pick(ABOUT_INTROS(cityName, province, brand), hash(cityName) >>> 3) +
sloganSentence(slogan)
);
}
export function buildContactIntro(cityName, provinceName, siteName, slogan = "") {
if (!cityName) return "";
const province = provinceName || cityName;
const brand = siteName || `${cityName} نوبت`;
return (
pick(CONTACT_INTROS(cityName, province, brand), hash(cityName) >>> 7) +
sloganSentence(slogan)
);
}
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import cityData from "@/data/city.json";
import stateData from "@/data/state.json";
import { buildAboutIntro, buildContactIntro } from "@/lib/staticPageIntro";
import { buildCanonicalUrl, buildMainCanonicalUrl } from "@/lib/getCanonicalUrl";
const CITIES = ["بهبهان", "یاسوج", "یزد", "تبریز", "اصفهان"];
describe("متن شهری صفحات استاتیک", () => {
it("دو شهر متن یکسان نمی‌گیرند", () => {
expect(buildAboutIntro("بهبهان", "خوزستان", "بهبهان نوبت")).not.toBe(
buildAboutIntro("یاسوج", "کهگیلویه و بویراحمد", "یاسوج نوبت")
);
});
it("یک شهر در دو فراخوانی همان متن را می‌دهد", () => {
expect(buildAboutIntro("بهبهان", "خوزستان", "بهبهان نوبت")).toBe(
buildAboutIntro("بهبهان", "خوزستان", "بهبهان نوبت")
);
});
it("درباره ما و تماس با ما برای یک شهر متن یکسان نمی‌گیرند", () => {
// seed هر دو از نام شهر می‌آید؛ اگر جابه‌جایی بیت نبود، یک ساختار می‌گرفتند.
const about = buildAboutIntro("یزد", "یزد", "یزد نوبت");
const contact = buildContactIntro("یزد", "یزد", "یزد نوبت");
expect(about).not.toBe(contact);
});
it("نام شهر و استان و برند در متن می‌آیند", () => {
const text = buildAboutIntro("بهبهان", "خوزستان", "بهبهان نوبت");
expect(text).toContain("بهبهان");
expect(text).toContain("خوزستان");
});
it("شهر بدون نام، متن خالی می‌دهد و صفحه را نمی‌شکند", () => {
expect(buildAboutIntro(undefined, "خوزستان", "نوبت 724")).toBe("");
expect(buildContactIntro(null, null, null)).toBe("");
});
it("استان نامشخص جای خالی و undefined در متن نمی‌گذارد", () => {
const text = buildContactIntro("شهر تازه", undefined, undefined);
expect(text).not.toContain("undefined");
expect(text).toContain("شهر تازه");
});
it("متن‌ها روی شهرهای واقعی به یک قالب واحد نمی‌چسبند", () => {
const texts = CITIES.map((c) => buildAboutIntro(c, "استان", `${c} نوبت`));
expect(new Set(texts).size).toBeGreaterThan(1);
});
it("روی همهٔ شهرهای واقعی بدون خطا اجرا می‌شود", () => {
for (const city of cityData) {
const province = stateData.find((s) => s.id === city.province_id);
const text = buildAboutIntro(city.name, province?.name, city.site_name);
expect(typeof text).toBe("string");
expect(text).not.toContain("undefined");
}
});
it("دو شهرِ هم‌variant با جای‌گذاری نام تمایز نمی‌گیرند، با شعار می‌گیرند", () => {
// سنجیده شد: بهبهان و یاسوج روی هر دو صفحه یک variant می‌گرفتند و تفاوتشان
// فقط نام شهر بود — همان چیزی که duplicate را برنمی‌گرداند.
const cityOf = (name) => cityData.find((c) => c.name === name);
const behbahan = cityOf("بهبهان");
const yasuj = cityOf("یاسوج");
const strip = (text, city) => text.split(city).join("«شهر»");
const a = strip(
buildAboutIntro(behbahan.name, "خوزستان", behbahan.site_name, behbahan.slogan),
behbahan.name
);
const b = strip(
buildAboutIntro(yasuj.name, "کهگیلویه و بویراحمد", yasuj.site_name, yasuj.slogan),
yasuj.name
);
expect(a).not.toBe(b);
});
it("هیچ دو شهری روی هیچ‌کدام از دو صفحه متن یکسان نمی‌گیرند", () => {
for (const build of [buildAboutIntro, buildContactIntro]) {
const texts = cityData.map((city) => {
const province = stateData.find((s) => s.id === city.province_id);
return build(city.name, province?.name, city.site_name, city.slogan);
});
expect(new Set(texts).size).toBe(cityData.length);
}
});
it("شهر بدون شعار، جملهٔ ناقص یا undefined نمی‌گیرد", () => {
const text = buildAboutIntro("شهر تازه", "استان", "برند", undefined);
expect(text).not.toContain("undefined");
expect(text).not.toContain("شعار ما همین است: .");
});
});
describe("canonical صفحات استاتیک", () => {
it("درباره ما و تماس با ما دیگر به دامنهٔ اصلی نمی‌روند", () => {
// این همان چیزی بود که Search Console «Alternate page with proper canonical»
// گزارش می‌کرد؛ صفحه روی هیچ دامنهٔ شهری ایندکس نمی‌شد.
for (const path of ["/about-us", "/contact-us"]) {
expect(buildCanonicalUrl("behbahan-nobat.ir", path)).toBe(
`https://behbahan-nobat.ir${path}`
);
expect(buildMainCanonicalUrl(path)).not.toBe(
buildCanonicalUrl("behbahan-nobat.ir", path)
);
}
});
});
+1
View File
@@ -7,6 +7,7 @@
},
"scripts": {
"dev": "cross-env HOST=yazd-nobat.localhost PORT=3000 NODE_TLS_REJECT_UNAUTHORIZED=0 next dev",
"prebuild": "node scripts/sync-specialties.mjs",
"build": "next build",
"start": "next start",
"lint": "eslint .",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

+80
View File
@@ -0,0 +1,80 @@
/**
* همگامسازی `data/specialties.json` با دیتابیس، در زمان build.
*
* node scripts/sync-specialties.mjs
*
* فایل قبلاً یک اسنپشات دستی بود و عقب میماند: تخصصی که ادمین میساخت در سایت
* نبود، پس صفحهاش ۴۰۴ میشد، در سایتمپ نمیآمد، و breadcrumb صفحهٔ پزشک به آن
* لینک نمیداد. نُه مصرفکننده از همین فایل میخوانند، از `app/sitemap.js` تا
* `helper/filterList`، پس منبع باید یکی و تازه بماند.
*
* فایل عمداً حذف نشد و runtime fetch جایش نیامد: `sitemap` و صفحات تخصص باید
* ایستا بمانند و به دسترسبودن API در زمان درخواست گره نخورند.
*
* فقط به `build` وصل است، نه `dev` توسعهٔ آفلاین نباید به API نیاز داشته باشد.
*
* بکاند محلی گواهی self-signed دارد، پس اجرای محلی مثل اسکریپت `dev` نیاز دارد:
*
* NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build
*
* این متغیر عمداً داخل `prebuild` ست نشده تا build تولیدی اعتبارسنجی TLS را از
* دست ندهد.
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const OUT = join(ROOT, 'data/specialties.json');
/**
* `.env` را Next میخواند، نه یک اسکریپت خام node. اینجا فقط برای اجرای محلی
* خوانده میشود و متغیرهای واقعیِ محیط را بازنویسی نمیکند در CI فایل نیست و
* مقدار از پلتفرم میآید.
*/
function loadDotEnv() {
const file = join(ROOT, '.env');
if (!existsSync(file)) return;
for (const line of readFileSync(file, 'utf8').split('\n')) {
const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (!match) continue;
const [, key, raw] = match;
if (process.env[key] === undefined) {
process.env[key] = raw.replace(/^["']|["']$/g, '');
}
}
}
loadDotEnv();
const API_URL = process.env.NEXT_PUBLIC_API_URL;
if (!API_URL) {
throw new Error('sync-specialties: NEXT_PUBLIC_API_URL is not set');
}
// `GET /api/v1/specialties` عمومی است و توکن نمی‌خواهد؛ بدون پارامتر `parent_id`
// همهٔ تخصص‌های فعال را می‌دهد، ریشه‌ها و فرزندان با هم. پاسخ دولایه است.
const res = await fetch(`${API_URL}/api/v1/specialties`);
if (!res.ok) {
throw new Error(`sync-specialties: HTTP ${res.status} from ${API_URL}`);
}
const items = (await res.json())?.data?.data;
// لیست خالی یعنی چیزی غلط است — پاسخ عوض شده، دیتابیس خالی است، یا پشت پراکسی
// نشسته‌ایم. بازنویسی با آن، سایت‌مپ را آب می‌کند و صفحات تخصص را از ایندکس
// می‌اندازد؛ شکستِ build از آن به‌مراتب ارزان‌تر است.
if (!Array.isArray(items) || items.length === 0) {
throw new Error('sync-specialties: empty list; refusing to overwrite data/specialties.json');
}
// ترتیب پایدار بر اساس id تا diff فایل نویزی نشود و مرور تغییرات ممکن بماند.
const sorted = [...items].sort((a, b) => a.id - b.id);
const before = JSON.parse(readFileSync(OUT, 'utf8'));
writeFileSync(OUT, `${JSON.stringify(sorted, null, 2)}\n`, 'utf8');
console.log(`sync-specialties: ${before.length}${sorted.length} specialties from ${API_URL}`);
+29 -3
View File
@@ -9,6 +9,15 @@ import api from "./api";
const clinicQuery = (clinicUuid, prefix = "&") =>
clinicUuid ? `${prefix}clinic_uuid=${encodeURIComponent(clinicUuid)}` : "";
/**
* آرایه را با `[]` سریالایز میکند؛ `params` اکسیوس این کار را نمیکند و backend
* فهرست سرویسها را خالی میبیند.
*/
const serviceQuery = (serviceItemUuids = []) =>
serviceItemUuids
.map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`)
.join("");
export const request = {
sendCode: (mobile, altcha, domain) =>
api.post(
@@ -81,12 +90,29 @@ export const request = {
getServiceSlots: (doctor_uuid, date, serviceItemUuids = [], clinic_uuid = null) =>
api.get(
`api/v1/appointment-service-slots?doctor_uuid=${doctor_uuid}&date=${date}` +
serviceItemUuids
.map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`)
.join("") +
serviceQuery(serviceItemUuids) +
clinicQuery(clinic_uuid),
removeTokenHead
),
// نوبت‌دهی منبع‌محور: منبع تقویم خودش را دارد، پس اسلات و تقویمِ ماهش هم از
// اندپوینت‌های خودش می‌آید نه از برنامهٔ هفتگی پزشک.
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=${encodeURIComponent(resource_uuid)}&date=${date}` +
serviceQuery(serviceItemUuids),
removeTokenHead
),
getResourceMonthAvailability: (resource_uuid, year, month, serviceItemUuids = []) =>
api.get(
`api/v1/appointment-resource-month-availability/${resource_uuid}?year=${year}&month=${month}` +
serviceQuery(serviceItemUuids),
removeTokenHead
),
postAppointment: (data) => api.post(`api/v1/appointment`, data, { requireAuth: true }),
getMyAppointments: (params) => api.get(`api/v1/appointments/user`, { params, requireAuth: true }),
getPaymentConfig: () => api.get(`api/v1/payment/config`, { requireAuth: true }),
+52
View File
@@ -73,4 +73,56 @@ describe('request wrappers — قرارداد به لایه‌ی API', () => {
request.postAppointment({ slot: 5 });
expect(api.post).toHaveBeenCalledWith('api/v1/appointment', { slot: 5 }, { requireAuth: true });
});
// --- نوبت‌دهی منبع‌محور ---
it('getBookingResources → بدون کلینیک، مسیر ساده', () => {
request.getBookingResources('doc-1');
expect(api.get.mock.calls[0][0]).toBe('api/v1/appointment-booking-resources/doc-1');
});
it('getBookingResources → با کلینیک، clinic_uuid با ? می‌آید', () => {
request.getBookingResources('doc-1', 'cl-1');
expect(api.get.mock.calls[0][0]).toBe(
'api/v1/appointment-booking-resources/doc-1?clinic_uuid=cl-1'
);
});
it('getResourceSlots → هر سرویس یک service_item_uuids[] جدا می‌گیرد', () => {
request.getResourceSlots('res-1', '2026-08-10', ['s1', 's2']);
const url = api.get.mock.calls[0][0];
expect(url).toContain('resource_uuid=res-1');
expect(url).toContain('date=2026-08-10');
expect(url).toContain('&service_item_uuids[]=s1');
expect(url).toContain('&service_item_uuids[]=s2');
});
it('getResourceSlots → uuid با کاراکتر ویژه encode می‌شود', () => {
request.getResourceSlots('res-1', '2026-08-10', ['a/b']);
expect(api.get.mock.calls[0][0]).toContain('service_item_uuids[]=a%2Fb');
});
it('getResourceSlots → فهرست خالی هیچ پارامتر سرویسی نمی‌سازد', () => {
request.getResourceSlots('res-1', '2026-08-10', []);
expect(api.get.mock.calls[0][0]).not.toContain('service_item_uuids');
});
it('getResourceMonthAvailability → سال و ماه و سرویس‌ها در query', () => {
request.getResourceMonthAvailability('res-1', 2026, 9, ['s1']);
const url = api.get.mock.calls[0][0];
expect(url).toContain('api/v1/appointment-resource-month-availability/res-1');
expect(url).toContain('year=2026');
expect(url).toContain('month=9');
expect(url).toContain('&service_item_uuids[]=s1');
});
it('هر سه اندپوینت عمومی‌اند — هدر Authorization حذف می‌شود', () => {
request.getBookingResources('doc-1');
request.getResourceSlots('res-1', '2026-08-10', ['s1']);
request.getResourceMonthAvailability('res-1', 2026, 9, ['s1']);
for (const call of api.get.mock.calls) {
expect(call[1]?.headers?.Authorization).toBe('');
}
});
});