Author SHA1 Message Date
hamedandClaude Opus 5 51c21938eb feat(booking): give the booking flow a dark palette derived from its own colours
components/appointment/ had every colour hard-coded, so the flow rendered
identically in either theme even after the wiring was fixed. It now reads from
CSS custom properties.

These are not new colours. The :root values are byte-for-byte the hex codes
that were already in the components — twenty-one files, mapped one to one — so
light mode is unchanged. The dark values are derived from those same colours:
surfaces and borders darkened, text inverted, and the two brand colours (the
indigo and the orange) lightened rather than replaced, because both lose
contrast against a dark surface at their original values.

Verified in a headless browser with prefers-color-scheme forced dark:
data-theme lands on <html> and --ap-surface resolves to #1b1b20 rather than
white. Six one-off colours remain hard-coded — a success green, an error red
and similar — each used exactly once and none of them a surface.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:33:39 +03:30
hamedandClaude Opus 5 2fb17d778a fix(theme): the public pages were writing data-, not data-theme
Chasing the dark-mode wiring to the end turned up the actual cause. next-themes
was configured with attribute="data-", which sets an attribute whose literal
name is "data-": the page rendered <html data-="dark">. Both globals.css and
the project's own documentation assume data-theme, so the transition rule keyed
on [data-theme] never applied either, and no Tailwind selector could have
matched. It is data-theme now, verified in a headless browser with
prefers-color-scheme forced to dark.

That is one of two reasons the booking flow looks the same in either theme. The
other is simply that components/appointment/ contains zero dark: utilities —
nothing there was ever styled for dark. The wiring is fixed and the 66 dark
rules that do exist now compile against the real attribute; giving the booking
flow a dark palette is design work, not a wiring bug, and it is not something
this pass invents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:13:11 +03:30
hamedandClaude Opus 5 519de26839 fix(theme): make dark mode actually apply to the public pages, and test the modals
Tailwind was configured with darkMode: "class" while next-themes writes
data-theme="dark" on the public pages. Every dark: utility on the public
site — 111 of them — compiled to a selector that never matched, which is why
the booking flow stayed white in dark mode. The variant strategy now accepts
both .dark (the panel) and [data-theme="dark"] (the public pages), so neither
provider had to change and 66 dark rules now compile against the real
attribute.

The cancel and reschedule modals get tests, the first component tests in this
repo. They pin the things that would be silently wrong: the penalty comes from
the server before anything is cancelled, the free window says "no penalty"
rather than showing a zero, confirming actually sends the request (the old
dialog's confirm button only closed it), a failed preview does not block the
cancellation, slots are requested with exclude_appointment_uuid so the
patient's own hour is not shown as taken, and the reschedule sends only the
start time because the server owns the duration.

The amount assertion deliberately checks the number and unit rather than the
digit shape — numberToArStyle uses the ar-AE locale and its output depends on
the ICU data in the environment.

lib/getStateInfo.test.js had been failing since before this work: an unknown
host falls through to the representation API, so the test made a real network
call and timed out after five seconds. It mocks lib/req now. The suite is
fully green for the first time: 158 tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:06:49 +03:30
hamedandClaude Opus 5 4c912dc6c1 feat(panel): let a patient actually cancel or move their own appointment
The site's own copy has been promising cancellation for a while —
lib/specialtyContent.js and components/appointment/information/Detail.js both
tell users they can cancel from "نوبت‌های من" and get a refund up to five hours
before the visit. The UI never could. The cancel modal existed, but its confirm
button called handleClose: it closed the dialog and sent nothing. And the whole
button row it lived in (ButtonData.js) had been commented out since the PDF
download commit, so it was not even reachable.

Cancelling now goes through POST /appointment/{uuid}/cancel and shows the
penalty from /cancellation-preview before the confirm — the same calculation
the cancel itself runs, so the number the patient sees is the number they are
charged. If the preview fails, the dialog says so rather than blocking; the
cancellation is still allowed.

Rescheduling is new and service-aware. It asks for slots with
exclude_appointment_uuid, so the patient's own hour counts as free rather than
showing as taken, and it sends only the start time — the server computes the
duration. Sending a client-side duration would mean two parallel calculations,
and the day a service's minutes change the appointment would move with a stale
one. The services on the appointment are carried over unchanged.

Both actions only appear for an appointment that is still in the future and not
already cancelled, and both refresh the list afterwards so a just-cancelled
appointment stops showing as confirmed.

The day strip in the reschedule modal is its own small component rather than
the booking flow's DatePicker: that one reads the doctor uuid from route params
and would be undefined inside the dashboard.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:32:10 +03:30
hamedandClaude Opus 5 15edc628e4 docs: describe the two booking modes and their front-end contract
Records what the service flow actually guarantees: mode is per location (a doctor
can be slot-based in their office and service-based in a clinic), duration is
server data and must never be summed in the front, and shift boundaries are not
derived client-side because a flat start_times list cannot tell a break between
shifts from a gap left by a booked appointment.

Also notes that user-panel reads of service fields are guarded, since slot-mode
appointments carry none of them.

Task: clinicpro/docs/new_feture/taskes/task-00b-nobat724-service-mode/

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:59:17 +03:30
hamedandClaude Opus 5 ae958eee73 feat(booking): show services and duration on the user panel appointment
A patient who booked in service mode could not see which services they had
reserved or how long the appointment was. Both now appear on the list card and in
both detail layouts, reading service_items and service_total_minutes from
GET /api/v1/appointments/user.

Every field is behind an explicit guard. Slot-mode appointments carry none of
them, and an unguarded map would crash the card for every slot-mode appointment,
taking the whole panel with it. A reserve entry shows its services but not a
duration, because it has no time.

Covered by Card.test.jsx: slot-mode appointments render unchanged, absent fields
(older backend) do not crash, reserve hides the duration, and a zero duration
produces no row.

Task: clinicpro/docs/new_feture/taskes/task-00b-nobat724-service-mode/

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:53:18 +03:30
hamedandClaude Opus 5 d58eec1dba fix(booking): report the real time range for service-mode slots
adaptServiceSlots labelled its single session "زمان‌های خالی" and set end_time to
the last slot's *start* time, so the range shown was shorter than reality. It now
uses the last slot's end_time (falling back to start + total_duration_minutes) and
labels the session with the actual range.

Shift separation was requested but is not implementable from this payload: with a
flat start_times list, a gap between shifts is indistinguishable from a gap left
by a booked appointment. The normal step is duration + buffer, so any threshold
that splits shifts either splits every slot of a long service (step above the
threshold) or invents tabs around booked appointments. A fabricated tab claims a
shift that does not exist, which is worse than one correct tab. Real grouping
belongs to the server-side endpoint task 06 adds.

Also repairs three tests that had been red since adaptSlots changed shape: they
still asserted the old { morning, evening } contract while the function returns an
array of sessions. Suite goes from 4 failures to 1 (an unrelated pre-existing
getStateInfo network timeout).

Task: clinicpro/docs/new_feture/taskes/task-00b-nobat724-service-mode/
Slot-mode contract: adaptSlots untouched

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:50:22 +03:30
hamedandClaude Opus 5 f3659f827e feat(booking): take service duration from the backend, not a parallel client sum
The service picker summed duration_minutes itself while the backend already
returns total_duration_minutes. Two sources of truth: when the formula changes to
solo/additional minutes, the site would keep showing the old number and the
patient would see a duration that does not match their appointment.

The picker runs before the date step and appointment-service-slots needs a date,
so it is called with today. total_duration_minutes does not depend on the date —
the backend computes it before touching that day's shifts, so the number is right
even when today is closed and start_times comes back empty.

The label reads "مدت تقریبی" until the server number arrives, then "مدت کل".
A missing field or a failed request falls back to the client sum with a
console.warn rather than blanking the step.

Staleness is derived from the selection key instead of reset in the effect body,
which also clears the set-state-in-effect lint warning.

Task: clinicpro/docs/new_feture/taskes/task-00b-nobat724-service-mode/

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:50:07 +03:30
132 changed files with 2208 additions and 8795 deletions
-422
View File
@@ -1,422 +0,0 @@
# نمایش چندتخصصی پزشک در کارت، فیلترها و پوستر
## پروژه
`nobat724_front` — سایت عمومی نوبت‌دهی.
**cross-repo.** پرامپت همتا: `clinicpro/.claude/prompt/doctor-multi-specialty-search.md`.
**آن را اول اجرا کن.** وظیفهٔ ۳ اینجا به کلید `parent_id` در `specialties[]` پاسخ
`GET /api/v1/doctors` نیاز دارد که همان‌جا اضافه می‌شود.
## زمینه
هر پزشک چند تخصص دارد و ساختار دو سطحی والد/فرزند است. نمونهٔ واقعی — دکتر محمدباقر جهانتاب
شش تخصص دارد:
```
13 جراحی عمومی (والد)
14 جراحی پلاستیک و زیبایی
167 جراحی لاپاراسکوپی
168 جراح تیروئید
169 جراح گوارش
170 جراحی سرطانها
```
## مشکل / هدف
چهار چیز در سایت عمومی می‌شکند.
### ۱. `data/specialties.json` کهنه است — ریشهٔ بقیهٔ مشکلات
فایل ثابت است و با دیتابیس همگام نیست:
- دیتابیس ۹۹ تخصص دارد، فایل ۹۴ تا.
- پنج فرزندِ `جراحی عمومی` با شناسه‌های ۱۶۷ تا ۱۷۱ در فایل **نیستند** — دقیقاً همان‌هایی که
این پزشک دارد.
این فایل در ۹ جا استفاده می‌شود: `app/sitemap.js`، `app/specialties/[slug]/page.js`،
`app/doctor/[slug]/page.js`، `components/home/search/Fields.js`، `components/specialties/index.js`،
`components/specialties/list/ItemSpecialties.js`، `components/doctor/index.js`،
`components/clinics/index.js`، `helper/index.js`.
نتیجه: `/specialties/جراح-گوارش` برابر ۴۰۴ است، لینک breadcrumb صفحهٔ پزشک می‌شکند، و
تخصص در سایت‌مپ نیست. تا این حل نشود، ساختن UI والد/فرزند ممکن نیست.
### ۲. انتخاب گروه، بی‌صدا به اولین زیرتخصص محدود می‌شود
کاربر `جراحی عمومی` را می‌زند و بدون اینکه بداند، فیلتر روی `جراحی پلاستیک و زیبایی` می‌نشیند.
هیچ گزینه‌ای برای «کل گروه» وجود ندارد.
### ۳. کارت پزشک همهٔ تخصص‌ها را پشت‌سرهم چاپ می‌کند
در موبایل ارتفاع کارت باد می‌کند و شبکه به‌هم می‌ریزد.
### ۴. پوستر سرریز می‌کند
چهار چیپ اول با نام‌های بلند، به سه ردیف می‌روند، بخش hero بلند می‌شود و بخش‌های پایین از
کادر ثابت `1080×1350` با `overflow-hidden` بیرون می‌زنند.
## معیار پذیرش
- ✅ موفق: بعد از `npm run build`، فایل `data/specialties.json` هر ۹۹ تخصص فعال را با
`parent_id` و `slug` دارد و `/specialties/جراح-گوارش` صفحه می‌دهد نه ۴۰۴.
- ✅ موفق: در مودال فیلترها، انتخاب گروه `جراحی عمومی` گزینهٔ پیش‌فرض
«همه تخصص‌های جراحی عمومی» را می‌گذارد و درخواست با `specialty_id=13` می‌رود.
- ✅ موفق: کارت دکتر جهانتاب در موبایل و دسکتاپ فقط `جراحی عمومی +5` نشان می‌دهد و ارتفاعش با
کارت پزشک تک‌تخصصی یکی است.
- ✅ موفق: کلیک روی `+5` فهرست کامل شش تخصص را در Popover نشان می‌دهد و **صفحهٔ پزشک را باز نمی‌کند**.
- ✅ موفق: پوستر همان پزشک، هیچ محتوایی بیرون از کادر ۱۳۵۰ ندارد و تخصص‌ها روی هم نمی‌افتند.
- ❌ خطا: پزشک بدون هیچ تخصص → کارت بدون بخش تخصص و بدون `+0`، نه `undefined` و نه کرش.
- ❌ خطا: اسکریپت همگام‌سازی وقتی API در دسترس نیست → build با پیام روشن شکست بخورد و فایل
موجود را با آرایهٔ خالی بازنویسی **نکند**.
- ⚠️ مرزی: پزشک با دقیقاً یک تخصص → فقط نام، بدون چیپ `+N`.
- ⚠️ مرزی: پزشک با دو تخصص ریشهٔ متفاوت (مثلاً `جراحی عمومی` و `داخلی`) → اولین ریشه نمایش،
بقیه در `+N`.
- ⚠️ مرزی: پزشکی که فقط زیرتخصص دارد و هیچ ریشه‌ای ندارد → اولین تخصص آرایه نمایش داده شود.
- ⚠️ مرزی: پوستر پزشکی با نام تخصص خیلی بلند → بریدن روی مرز کلمه، نه وسط کلمه.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `scripts/sync-specialties.mjs` | **جدید** — ساخت `data/specialties.json` از API |
| `package.json` | قلاب `prebuild` |
| `lib/specialtyDisplay.js` | **جدید** — قاعدهٔ «تخصص اصلی» مشترک بین کارت و پوستر |
| `helper/index.js` | `filterList()` خط ۲۷۰ |
| `components/doctors/modal/Content.js` | انتخاب اجباری اولین فرزند |
| `components/doctors/modal/form/index.js` | دو `CateSelector` |
| `app/component/ItemDoctor.js` | کارت پزشک |
| `app/component/PopoverDate.js` | الگوی موجود Popover — تقلید کن، از نو ننویس |
| `components/doctor/poster/index.js` | پوستر تیره |
| `components/doctor/poster/PosterLight.js` | پوستر روشن — همان باگ |
| `components/doctors/search/SearchField.js` | placeholder کادر جستجو |
## وضعیت فعلی
### انتخاب اجباری اولین زیرتخصص
```js
// components/doctors/modal/Content.js:5
const changeSpecialty = (name, value) => {
const newFilter = { ...filter, [name]: value };
if (name === "category") {
if (value) {
const filtered = specialties
.filter((item) => item.parent_id)
.filter((item) => String(item.parent_id) === String(value.id));
newFilter.specialty = filtered[0] || null;
} else {
newFilter.specialty = null;
}
}
setFilter(newFilter);
setDataInURL(newFilter);
return newFilter;
};
```
و ساخت پارامتر، `specialty` را بر `category` ترجیح می‌دهد:
```js
// helper/index.js:482
if (newFilter.specialty?.id) {
params.specialty_id = newFilter.specialty.id;
} else if (newFilter.category?.id) {
params.specialty_id = newFilter.category.id;
}
```
```js
// helper/index.js:270
export const filterList = (data) => {
const parentList = specialties.filter((item) => !item.parent_id);
const childrenList = specialties.filter((item) => item.parent_id);
const filteredChildrenList =
data && data.category
? childrenList.filter(
(item) => String(item.parent_id) === String(data.category.id)
)
: [];
return {
parentList: parentList,
childrenList: filteredChildrenList,
};
};
```
### کارت — همه را پشت‌سرهم چاپ می‌کند
```jsx
// app/component/ItemDoctor.js:46
<TextLoading loading={loading} width={120} height={15}>
<p className="text-[#616161] text-[14px] font-normal">
تخصص:
{doctor?.specialties?.map(
(item, idx) =>
`${item.name} ${doctor.specialties.length === idx + 1 ? "" : "|"} `
)}
</p>
</TextLoading>
```
### پوستر — برش کور روی عدد ۴
```jsx
// components/doctor/poster/index.js:44 (و PosterLight.js:24 دقیقاً همین)
const specialties = (data?.specialties ?? []).filter((s) => s?.name).slice(0, 4);
```
```jsx
// components/doctor/poster/index.js:132
<div className="flex flex-wrap gap-[10px] mt-[16px]">
{specialties.map((s, i) => (
<span
key={i}
className="flex items-center rounded-full bg-[#F59E0B]/18 border border-[#F59E0B]/40 px-[18px] py-[9px]"
>
<span className="text-[#FCD9A0] text-[22px] font-semibold leading-none">
{s.name}
</span>
</span>
))}
</div>
```
کادر ثابت است، پس هر ردیف اضافه محتوای پایین را بیرون می‌اندازد:
```jsx
// components/doctor/poster/index.js:81
<div
dir="rtl"
className="relative w-[1080px] h-[1350px] overflow-hidden flex flex-col p-[64px] text-right"
```
## وظایف
### ۱. اسکریپت همگام‌سازی `data/specialties.json`
فایل جدید `scripts/sync-specialties.mjs`.
منبع: `GET ${NEXT_PUBLIC_API_URL}/api/v1/specialties`**عمومی است و توکن نمی‌خواهد**.
بدون پارامتر `parent_id` همهٔ تخصص‌های فعال را می‌دهد. پاسخ دولایه است:
`{ success, data: { data: [...] } }`.
هر آیتم دقیقاً شکل فایل فعلی را دارد: `id` و `uuid` و `name` و `slug` و `status` و `weight`
و `parent_id`.
```js
// scripts/sync-specialties.mjs
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const OUT = new URL("../data/specialties.json", import.meta.url);
const res = await fetch(`${API_URL}/api/v1/specialties`);
if (!res.ok) throw new Error(`specialties sync failed: HTTP ${res.status}`);
const json = await res.json();
const items = json?.data?.data ?? [];
// آرایهٔ خالی یعنی چیزی غلط است — فایل موجود را با آن بازنویسی نکن.
if (!Array.isArray(items) || items.length === 0) {
throw new Error("specialties sync returned an empty list; refusing to overwrite");
}
```
قبل از نوشتن، خروجی را با ترتیب پایدار مرتب کن (بر اساس `id`) تا diff فایل نویزی نشود،
و با دو فاصله و `\n` انتهایی بنویس تا با شکل فعلی فایل بخواند.
در `package.json` قلاب بزن:
```json
"prebuild": "node scripts/sync-specialties.mjs",
```
`dev` را وصل نکن — توسعهٔ آفلاین نباید به API گره بخورد.
**نحوه تست:**
```bash
cd nobat724_front
node scripts/sync-specialties.mjs
python3 -c "import json;d=json.load(open('data/specialties.json'));print(len(d), [x['slug'] for x in d if x.get('parent_id')==13])"
```
باید ۹۹ و شامل `جراح-گوارش` باشد. سپس با `NEXT_PUBLIC_API_URL` غلط اجرا کن و مطمئن شو
خطا می‌دهد و فایل دست‌نخورده می‌ماند.
بعد `npm run build` و باز کردن `/specialties/جراح-گوارش`.
### ۲. گزینهٔ «همه تخصص‌های X»
`filterList` در `helper/index.js` یک آیتمِ «همه» به ابتدای `childrenList` اضافه کند که
شناسه‌اش همان شناسهٔ والد است:
```js
const filteredChildrenList =
data && data.category
? [
// «همه» یعنی فیلتر روی خودِ گروه؛ بک‌اند specialty_id را به نوادگان گسترش می‌دهد.
{ id: data.category.id, name: `همه تخصص‌های ${data.category.name}`, parent_id: null },
...childrenList.filter(
(item) => String(item.parent_id) === String(data.category.id)
),
]
: [];
```
و در `Content.js` به‌جای `filtered[0]`، همان آیتم «همه» انتخاب شود:
```js
if (name === "category") {
// پیش‌فرض «کل گروه» است، نه اولین زیرتخصص. انتخاب بی‌صدای اولین فرزند،
// جستجوی کاربر را بدون اطلاعش تنگ می‌کرد.
newFilter.specialty = value
? { id: value.id, name: `همه تخصص‌های ${value.name}` }
: null;
}
```
`QueryForDoctorsReq` را دست نزن — چون شناسهٔ «همه» همان شناسهٔ والد است، همان مسیر فعلی
`specialty_id` را درست می‌فرستد.
**نحوه تست:** unit test در `helper/specialtyFilter.test.js` برای `filterList`
با `category` برابر `جراحی عمومی` اولین آیتم `childrenList` باید `id` والد و عنوان
«همه تخصص‌های جراحی عمومی» داشته باشد؛ بدون `category` آرایه خالی بماند.
سپس دستی: مودال فیلترها → گروه `جراحی عمومی` → در تب شبکه ببین `specialty_id=13` می‌رود
و دکتر جهانتاب در نتایج هست.
### ۳. قاعدهٔ مشترک «تخصص اصلی»
فایل جدید `lib/specialtyDisplay.js`. کارت و پوستر هر دو از این می‌خوانند تا قاعده دو جا
تکرار و واگرا نشود.
```js
/**
* تخصص «اصلی» و بقیه.
*
* ریشه (بدون parent_id) اصلی است چون عنوانی است که بیمار می‌شناسد و هنگام ذخیره در
* بک‌اند خودکار به پزشک اضافه می‌شود، پس تقریباً همیشه وجود دارد. اگر ریشه‌ای نبود،
* اولین آیتم آرایه.
*/
export function splitSpecialties(list) {
const items = (list ?? []).filter((s) => s?.name);
if (items.length === 0) return { primary: null, rest: [] };
const primary = items.find((s) => !s.parent_id) ?? items[0];
return { primary, rest: items.filter((s) => s !== primary) };
}
```
**نحوه تست:** `lib/specialtyDisplay.test.js` — آرایهٔ خالی؛ تک‌تخصص؛ ریشه وسط آرایه؛
هیچ ریشه‌ای نبودن؛ آیتمِ بدون `name` که باید حذف شود.
### ۴. کارت پزشک — تخصص اصلی و `+N`
در `app/component/ItemDoctor.js` بلوک `<p>` تخصص با این جایگزین شود:
```jsx
const { primary, rest } = splitSpecialties(doctor?.specialties);
```
نمایش: نام `primary`، و اگر `rest.length > 0` یک چیپ کوچک `+{rest.length}` کنارش.
کلیک روی چیپ، `Popover` باز کند با فهرست کامل (`primary` و `rest`).
الگوی `Popover` را از `app/component/PopoverDate.js` بردار، از صفر ننویس.
سه نکتهٔ اجباری:
- روی `onClick` چیپ حتماً `e.preventDefault()` و `e.stopPropagation()` بزن. کارت داخل
`Link` است و بدون این، کلیک صفحهٔ پزشک را باز می‌کند.
- چیپ باید `<button type="button">` باشد با `aria-label` روشن مثل
`نمایش ${rest.length} تخصص دیگر`، نه `<span>` با `onClick`.
- ارتفاع کارت نباید تغییر کند. نام `primary` در یک خط با `truncate` بماند.
استایل چیپ با توکن‌های موجود همان فایل: `text-[12px]` و `rounded-[4px]` و
`bg-[#F8F8FF]` و `text-[#616161]` — همان چیزی که بلوک امتیاز و رضایت استفاده می‌کند.
رنگ یا کلاس تازه اضافه نکن.
**نحوه تست:** `npm run test` با یک تست کامپوننتی —
پزشک با ۶ تخصص: متن `جراحی عمومی` هست و `+5` هست؛ کلیک روی `+5` هر شش نام را نشان می‌دهد؛
پزشک با ۱ تخصص: هیچ `+` در DOM نیست؛ پزشک بدون تخصص: کرش نمی‌کند.
سپس دستی در موبایل روی `/doctors` — ارتفاع کارت جهانتاب با کارت کناری یکی باشد.
### ۵. پوستر — والد درشت، زیرتخصص‌ها یک خط متنی
**هر دو فایل** `components/doctor/poster/index.js` و `components/doctor/poster/PosterLight.js`.
هر دو دقیقاً همان `slice(0, 4)` را دارند.
منطق پوستر عمداً با کارت فرق دارد: در چاپ نه کلیک هست نه Popover، پس `+N` بی‌معناست.
در `lib/specialtyDisplay.js` یک تابع دوم بگذار:
```js
/**
* زیرتخصص‌ها به‌شکل یک خط متنی، بریده روی مرز کلمه با بودجهٔ کاراکتر.
* پوستر کادر ثابت دارد و overflow-hidden است؛ هر ردیف اضافه، بخش‌های پایین را بیرون می‌اندازد.
*/
export function posterSpecialtyLine(rest, budget = 90) {
const names = rest.map((s) => s.name);
const shown = [];
let used = 0;
for (const name of names) {
const cost = name.length + (shown.length ? 3 : 0); // ' · '
if (used + cost > budget) break;
shown.push(name);
used += cost;
}
const hidden = names.length - shown.length;
return { text: shown.join(" · "), hidden };
}
```
در پوستر: `primary` همان چیپ درشت فعلی بماند (فقط یکی، نه چهارتا).
زیرش یک `<p>` با `text-[20px]` و رنگ کم‌رنگ‌تر موجود (`#C7DBF2` در تیره، معادلش در روشن)
که `text` را نشان می‌دهد و اگر `hidden > 0` بود `و {hidden} تخصص دیگر` را به آن می‌چسباند.
روی ظرف زیرتخصص‌ها `max-h` بگذار معادل دو خط، تا حتی اگر بودجه اشتباه تنظیم شد،
کادر سرریز نکند.
**نحوه تست:** تست واحد `posterSpecialtyLine` — بودجهٔ کوچک با نام‌های بلند؛ آرایهٔ خالی
(`text` تهی و `hidden` صفر)؛ یک نام بلندتر از کل بودجه (نباید وسط کلمه بریده شود).
سپس دستی: پوستر دکتر جهانتاب را از صفحهٔ پزشک بساز و مطمئن شو در هر دو تم روشن و تیره
هیچ چیزی بیرون از کادر نیست.
### ۶. placeholder کادر جستجو
```js
// components/doctors/search/SearchField.js:58
placeholder="جستجوی نام پزشک ..."
```
به «جستجوی نام پزشک یا تخصص ...» تغییر کند. بک‌اند بعد از پرامپت همتا نام تخصص را هم می‌گردد
و placeholder فعلی دروغ می‌شود.
**نحوه تست:** تایپ `جراح گوارش` در کادر و دیدن دکتر جهانتاب در نتایج. این تست فقط بعد از
اجرای پرامپت بک‌اند معنا دارد.
## نکات مهم
- **ترتیب اجرا اجباری است.** وظیفهٔ ۳ و ۴ بدون `parent_id` در پاسخ API کار نمی‌کنند.
اگر بک‌اند هنوز اجرا نشده، `splitSpecialties` همیشه `items[0]` را برمی‌گرداند و
نتیجه ظاهراً درست ولی غیرقابل‌اتکا می‌شود.
- **وظیفهٔ ۱ پیش‌نیاز وظیفهٔ ۲ است.** بدون فایلِ به‌روز، فرزندهای ۱۶۷ تا ۱۷۱ در
`filterList` نیستند و گزینهٔ «همه» روی گروهی می‌نشیند که فرزندانش را نمی‌بیند.
- **یک قاعده، یک جا.** انتخاب «تخصص اصلی» فقط در `lib/specialtyDisplay.js`. اگر در
`ItemDoctor` یا پوستر دوباره نوشته شود، فردا دو جا واگرا می‌شوند. این همان دلیل ساختن
فایل است، نه abstraction برای آینده.
- **کلیک داخل `Link`.** بدون `stopPropagation` روی چیپ `+N`، هر بار که کاربر تخصص‌ها را
می‌بیند به صفحهٔ پزشک پرت می‌شود. این را حتماً تست کن.
- **پوستر دو فایل است.** `index.js` تیره و `PosterLight.js` روشن. اصلاح یکی و فراموشی
دیگری، باگ را نصفه رها می‌کند.
- **سئو.** `data/specialties.json` به `app/sitemap.js` هم خوراک می‌دهد. بعد از همگام‌سازی،
سایت‌مپ تخصص‌های تازه را می‌گیرد — این مطلوب است، ولی مطمئن شو اسکریپت در شکست، فایل را
خالی نمی‌کند وگرنه سایت‌مپ کوچک می‌شود و صفحات از ایندکس می‌افتند.
- **استایل.** MUI v5 و Tailwind و RTL و فونت Vazir. کلاس یا رنگ تازه اضافه نکن؛ از همان
توکن‌های موجود در همان فایل استفاده کن. راه‌حل با CSS موقت یا `!important` پذیرفته نیست.
- **صفحهٔ جزئیات پزشک عمداً خارج از محدوده است.** breadcrumb شکسته‌اش عارضهٔ فایل کهنه است و
با وظیفهٔ ۱ خودبه‌خود درست می‌شود. اگر بعد از وظیفهٔ ۱ باز هم شکسته بود، آیتم تازه به
todo اضافه کن و گزارش بده.
@@ -1,250 +0,0 @@
# رفع لینک `/doctor/undefined` و متمایزسازی صفحهٔ اصلی شهرها
## پروژه
`nobat724_front` — سایت عمومی. بدون تغییر بک‌اند.
سند همراه: `docs/seo/gsc-expected-exclusions.md` — آن خطاهای Search Console که **باگ نیستند**
و نباید «رفع» شوند. این پرامپت فقط دو موردِ واقعاً کدی را می‌سازد.
## زمینه
Search Console روی `behbahan-nobat.ir` و `yasuj-nobat.ir` شش دستهٔ مسئله نشان می‌دهد.
چهار دسته رفتار عمدی سیستم‌اند و در سند بالا توضیح داده شده‌اند. دو دسته باگ واقعی‌اند:
- **Not found (404)** — `https://behbahan-nobat.ir/doctor/undefined`، آخرین crawl ۳۰ ژوئیه
- **Duplicate, Google chose different canonical than user** — `https://behbahan-nobat.ir/`
## مشکل / هدف
### ۱. لینک `/doctor/undefined`
اسکلت بارگذاری صفحهٔ پزشکان، شش کارت با آبجکتِ بدون `uuid` رندر می‌کند. `ItemDoctor`
لینک را با `doctor?.uuid` می‌سازد، پس در HTML شش `<a href="/doctor/undefined">` می‌نشیند و
Googlebot همان را crawl می‌کند.
زنده تأیید شد:
```
curl -o /dev/null -w "%{http_code}" https://behbahan-nobat.ir/doctor/undefined
404
```
### ۲. صفحهٔ اصلی هر شهر تقریباً کپی بقیه است
canonical درست و self است — این را از خودِ سایت زنده گرفتم:
```
https://behbahan-nobat.ir/ → <link rel="canonical" href="https://behbahan-nobat.ir">
https://yasuj-nobat.ir/ → <link rel="canonical" href="https://yasuj-nobat.ir">
```
پس گوگل canonical ما را **رد کرده**، نه اینکه ما اشتباه اعلام کرده باشیم. دلیلش اندازه‌گیری شد:
```
شباهت متنِ رندرشدهٔ دو صفحهٔ اصلی: ۹۹.۰٪
```
تنها چیزِ شهرمحورِ صفحهٔ اصلی، `site_name` و `slogan` از `data/city.json` است. بقیه —
بنر، کادر جستجو، «جستجوهای پرتکرار»، فوتر — روی همهٔ دامنه‌ها یکسان است. تا محتوا
متمایز نشود هیچ تگی جلوی تجمیع را نمی‌گیرد؛ همین ریشهٔ بخش بزرگی از
`Discovered - currently not indexed` هم هست.
## معیار پذیرش
- ✅ موفق: در HTML صفحهٔ `/doctors` (چه در حالت اسکلت چه با داده) هیچ
`href="/doctor/undefined"` نباشد.
- ✅ موفق: صفحهٔ اصلی هر شهر یک بخش متنی و آماری دارد که با شهر دیگر فرق می‌کند —
شمار پزشک و کلینیک همان شهر، و تخصص‌های پرتکرارِ همان شهر.
- ✅ موفق: شباهت متنِ رندرشدهٔ دو صفحهٔ اصلی به زیر ۸۵٪ برسد (سنجهٔ عینی، دستور پایین).
- ❌ خطا: اگر API پاسخ ندهد یا شهر پزشکی نداشته باشد، صفحهٔ اصلی نباید بشکند و نباید
عدد صفر یا «undefined» نشان دهد — بخش آمار حذف می‌شود و بقیهٔ صفحه سالم می‌ماند.
- ❌ خطا: کارت اسکلت (بدون `uuid`) نباید اصلاً لینک باشد.
- ⚠️ مرزی: دامنهٔ ریشه `nobat724.com` شهر ندارد؛ متن و آمار شهری نباید آنجا رندر شود.
- ⚠️ مرزی: شهری که تازه اضافه شده و صفر پزشک دارد — پیام بی‌جایگزین نه، بلکه حذف بخش آمار.
- ⚠️ مرزی: `loading.js` روی مسیر `/doctors` است؛ اسکلت‌های دیگری که `ItemDoctor` را با
دادهٔ ناقص رندر می‌کنند هم باید همین رفتار را بگیرند.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `app/component/ItemDoctor.js` | سازندهٔ لینک پزشک — تنها جای پروژه |
| `app/doctors/loading.js` | اسکلتی که آبجکت بدون `uuid` می‌دهد |
| `components/home/index.js` | چیدمان صفحهٔ اصلی |
| `components/home/TextHeader.js` | تنها بخش شهرمحور فعلی |
| `components/home/FrequentSearches.js` | فهرست ثابت، یکسان روی همهٔ دامنه‌ها |
| `lib/getStateInfo.js` | تشخیص شهر از subdomain (server) |
| `lib/rootCity.js` | `isRootCity` — تشخیص دامنهٔ ریشه |
| `lib/specialtyContent.js` | `buildSpecialtyIntro` — الگوی متنِ یکتای موجود |
| `services/response.js` | `getSpecialtyDoctorCounts` — شمار پزشک هر تخصص در یک شهر |
## وضعیت فعلی
### لینک با uuid اختیاری
```jsx
// app/component/ItemDoctor.js:41
<Link href={`/doctor/${doctor?.uuid}`}>
<p className="text-[#3B3B3B] text-[14px] font-bold">
{doctor?.display_name || doctor?.name}
</p>
</Link>
```
```jsx
// app/component/ItemDoctor.js:89
<Link
href={`/doctor/${doctor?.uuid}`}
aria-label={`مشاهده پروفایل ${doctor?.display_name || doctor?.name || "پزشک"}`}
>
```
### اسکلتی که uuid ندارد
```jsx
// app/doctors/loading.js
{Array.from({ length: 6 }).map((_, idx) => (
<ItemDoctor key={idx} doctor={{ id: idx }} loading setDoctors={() => {}} />
))}
```
### تنها بخش شهرمحور صفحهٔ اصلی
```jsx
// components/home/TextHeader.js
const { matchedCity } = await getStateInfo();
...
<p className="…">با {matchedCity?.site_name}</p>
<h1 className="…">{matchedCity?.slogan}</h1>
```
```jsx
// components/home/index.js
<div className="padding-responsive z-20 min-h-screen py-[120px] flex items-center justify-center flex-col gap-[40px]">
<TextHeader />
<SearchBar />
<FrequentSearches />
</div>
```
### الگوی متنِ یکتا که از قبل هست و باید تقلید شود
```js
// lib/specialtyContent.js:57
export function buildSpecialtyIntro(specialtyName, cityName, doctorCount = 0) {
const seed = hash(specialtyName, cityName);
return [
pick(openingVariants(specialtyName, cityName), seed),
pick(bodyVariants(specialtyName, cityName, doctorCount), seed >>> 3),
pick(closingVariants(specialtyName, cityName), seed >>> 7),
].join(" ");
}
```
## وظایف
### ۱. کارت بدون `uuid` نباید لینک باشد
در `app/component/ItemDoctor.js` هر دو `Link` مشروط شوند. وقتی `uuid` نیست — اسکلت
بارگذاری، یا رکورد ناقص — همان محتوا بدون `<a>` رندر شود.
```jsx
const href = doctor?.uuid ? `/doctor/${doctor.uuid}` : null;
```
- عنوان: اگر `href` تهی بود، `<p>` بدون `Link`.
- دکمهٔ فلش: اگر `href` تهی بود، دکمه رندر نشود (در اسکلت هم معنایی ندارد).
`?.` را از `doctor?.uuid` داخل رشتهٔ الگو حذف کن؛ همان بود که `undefined` را به URL می‌برد.
**نحوه تست:** تست کامپوننتی در `app/component/ItemDoctor.test.js`
پزشک با `uuid` دو لینک به `/doctor/<uuid>` دارد؛ آبجکت `{ id: 0 }` هیچ `role="link"`
ندارد و رشتهٔ `undefined` در `container.innerHTML` نیست.
سپس build و بررسی HTML واقعی:
```bash
NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build
npm run start &
curl -s http://localhost:3000/doctors | grep -c 'doctor/undefined' # باید 0 باشد
```
### ۲. بخش شهرمحور صفحهٔ اصلی
کامپوننت تازه `components/home/CityHighlights.js` — سرور-کامپوننت، زیر `FrequentSearches`.
داده از `GET /api/v1/specialties/doctor-counts?city_id=<id>` می‌آید که از قبل هست و
`services/response.js` هم wrapper دارد (`getSpecialtyDoctorCounts`). برای صفحهٔ اصلی
server-side است، پس با `lib/req.js``fetchReq` بگیر، نه با کلاینت axios.
محتوا:
- یک پاراگراف یکتا با همان الگوی `buildSpecialtyIntro`: تابع تازه‌ای کنارش در
`lib/specialtyContent.js` به نام `buildCityIntro(cityName, doctorCount, topSpecialties)`
بنویس. **الگو Variant-pick با hash است، نه متن ثابت** — دلیلش همان دلیل تابع موجود:
متنِ یکسان روی ۳۰ دامنه دوباره همان duplicate را می‌سازد؛ seed از نام شهر می‌آید تا
هر شهر واگرا شود و خروجی هم پایدار بماند.
- شمار واقعی: «N پزشک در M تخصص در <شهر>».
- شش تا هشت تخصصِ پرپزشکِ **همان شهر** با شمارشان، لینک به `/specialties/<slug>`.
این جای `FrequentSearches` را نمی‌گیرد؛ آن فهرست ثابت است و این یکی شهرمحور.
روی دامنهٔ ریشه (`isRootCity`) کل بخش رندر نشود — آنجا شهری وجود ندارد.
```jsx
const { matchedCity, isRoot } = await getStateInfo();
if (isRoot || !matchedCity?.id) return null;
const counts = await safeCounts(matchedCity.id); // خطا → آرایهٔ خالی
if (counts.length === 0) return null; // شهر بدون پزشک → بخش حذف
```
خطا را همین‌جا ببلع و `null` برگردان. صفحهٔ اصلی مهم‌ترین صفحهٔ سایت است و نباید با
قطعی API سفید شود؛ این همان مرز واقعی error handling است که پروژه می‌پذیرد.
**نحوه تست:**
- unit test برای `buildCityIntro` در `lib/specialtyContent.test.js`: دو شهر متفاوت متن
متفاوت بدهند؛ یک شهر در دو فراخوانی متن یکسان بدهد (پایداری)؛ `doctorCount` صفر
جمله را نشکند.
- تست کامپوننتی `components/home/CityHighlights.test.js` با mock روی `getStateInfo` و
`fetchReq`: دامنهٔ شهری بخش را می‌سازد؛ دامنهٔ ریشه `null`؛ خطای شبکه `null`؛
فهرست خالی `null`.
- سنجهٔ عینیِ معیار پذیرش، بعد از deploy:
```bash
a=$(curl -s https://behbahan-nobat.ir/ | sed 's/<[^>]*>/ /g' | tr -s ' \n' ' ')
b=$(curl -s https://yasuj-nobat.ir/ | sed 's/<[^>]*>/ /g' | tr -s ' \n' ' ')
python3 -c "
import sys, difflib
print('%.1f%%' % (difflib.SequenceMatcher(None, sys.argv[1], sys.argv[2]).ratio()*100))
" "$a" "$b"
```
پیش از تغییر ۹۹٫۰٪ بود؛ باید زیر ۸۵٪ برود.
### ۳. `generateMetadata` صفحهٔ اصلی
`app/page.js` الان `generateMetadata` ندارد و از layout ارث می‌برد. توضیحات متا هم
باید شهرمحور باشد، وگرنه snippet هر ۳۰ دامنه یکی است.
`description` را از همان `buildCityIntro` بساز (کوتاه‌شده)، و `alternates.canonical`
را دست نزن — لایهٔ layout با `getCanonicalUrl` درستش می‌کند و صفحهٔ اصلی C1-a است.
**نحوه تست:** `curl -s https://<city>-nobat.ir/ | grep -oE '<meta name="description" content="[^"]*"'`
روی دو دامنه، و متفاوت بودنشان.
## نکات مهم
- **قرارداد API عوض نمی‌شود.** `doctor-counts` از قبل وجود دارد و مستند است. هیچ کاری در
`clinicpro` لازم نیست.
- **الگو: Variant-pick با hash** — همان که `lib/specialtyContent.js` برای صفحات تخصص
دارد. دلیل انتخابش این است که مسئله دقیقاً همان است: یک قالب روی ده‌ها دامنه که اگر
ثابت بماند دوباره duplicate می‌سازد. متن تصادفیِ ناپایدار هم بدتر است، چون هر
rebuild محتوا را عوض می‌کند؛ seed از نام شهر هر دو را حل می‌کند.
- **بخش آمار نباید عدد صفر نشان دهد.** شهرِ بدون پزشک با «۰ پزشک» بدتر از نبودِ بخش است.
- **`FrequentSearches` دست‌نخورده بماند.** آن فهرست ثابتِ تخصص‌هاست و کارکرد ناوبری دارد؛
بخش تازه مکمل آن است نه جایگزینش.
- **این تغییر SEO است و اثرش فوری نیست.** بعد از deploy باید در Search Console دوباره
ایندکس درخواست شود و هفته‌ها طول می‌کشد. در گزارش پایانی این را صریح بنویس تا انتظار
اشتباه ساخته نشود.
- **`Validate fix` را برای دسته‌های دیگر نزن.** دلیلش در سند همراه آمده؛ زدنش روی
noindex عمدی همیشه شکست می‌خورد و در Search Console نویز می‌سازد.
- خط پایهٔ فعلی ریپو پیش از این تسک: `npm run test` چهار شکستِ ازقبل‌موجود در
`lib/lib.test.js` و `lib/getStateInfo.test.js`، و `npm run lint` سه خطا در فایل‌های
بی‌ربط. این‌ها رگرسیون نیستند و درست کردنشان در محدودهٔ این تسک نیست.
@@ -1,469 +0,0 @@
# بازگرداندن صفحهٔ اصلی، رفع ۴۰۴ اسلاگ فارسی، سه اصلاح صفحهٔ مقاله، و دو اصلاح «درباره ما»
## پروژه
`nobat724_front` — سایت عمومی. بدون تغییر بک‌اند.
## زمینه
پنج مورد جدا که کاربر روی محیط محلی دید. یکی بازگرداندن تغییری است که در تسک قبلی
اضافه شد، یکی باگ ۴۰۴ با ریشهٔ اثبات‌شده، و سه اصلاح کوچک روی صفحهٔ مقاله.
## مشکل / هدف
### ۱. بخش تازهٔ صفحهٔ اصلی برداشته شود
در تسک قبلی برای رفع `Duplicate, Google chose different canonical` یک بخش شهرمحور
به صفحهٔ اصلی اضافه شد: متن مقدمه، «تخصص‌های پرمراجعه در …» و «پزشکان …».
کاربر آن را نمی‌خواهد. صفحهٔ اصلی به حالت قبل برگردد.
### ۲. اسلاگ فارسی ۴۰۴ می‌دهد
```
/specialties/%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4 → 404
/specialties/gastroenterology → 200
```
**ریشه با ابزارگذاری روی build واقعی اثبات شد**`generateMetadata` و بدنهٔ صفحه دو
مقدار متفاوت می‌گیرند:
```
generateMetadata → slug = "جراح-گوارش" found: true
بدنهٔ صفحه → slug = "%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4" found: false → notFound()
```
یعنی Next در بدنهٔ صفحه segment را decode نمی‌کند. رشته‌ها بایت‌به‌بایت یکی‌اند
(هر دو NFC، بدون تفاوت codepoint)، پس مشکل نرمال‌سازی نیست.
**این باگ فقط مخصوص صفحهٔ تخصص نیست.** slug مقاله‌ها هم فارسی است
(`آلرژی-دارویی-…-58d2e880`)، پس `app/blog/[slug]/page.js` هم همین را دارد و امروز
با uuid تست می‌شود که ASCII است و مشکل را پنهان می‌کند.
### ۳. برچسب «مخصوص شهر» از صفحهٔ مقاله حذف شود
فقط همین جمله. منطق scope شهریِ مقاله‌ها دست‌نخورده می‌ماند.
### ۴. تگ‌های مقاله لینک شوند
الان چیپ‌های بدون لینک‌اند. کامنت کد می‌گوید «صفحهٔ فیلتر تگ وجود ندارد» — این کامنت
کهنه است؛ `components/blogs/index.js` از قبل `?tag=` را می‌خواند و اعمال می‌کند.
### ۵. عکس «مطالب مرتبط» دفرمه است
عکس داخل `<li className="flex">` است و ظرفش `shrink-0` ندارد. با تیتر بلند، عرض عکس
فشرده می‌شود و ارتفاعش ثابت می‌ماند.
### ۶. صفحهٔ «درباره ما» راه عضویت پزشک را نمی‌گوید
مسیر صفحه:
```
/about-us
```
متن فعلی فقط از دید بیمار نوشته شده. پزشکی که بخواهد به سامانه اضافه شود هیچ
راهنمایی‌ای نمی‌بیند. باید بند تازه‌ای اضافه شود که او را به این سایت بفرستد:
```
https://clinic-pro.ir/
```
### ۷. لوگوی هدر «درباره ما» غلط است
هدر بنفش صفحهٔ «درباره ما» فایل قدیمی را نشان می‌دهد:
```
/assets/images/logo-2.png
```
هیچ‌جای دیگر سایت این فایل را مصرف نمی‌کند. لوگوی واقعی سایت این است:
```
/nobat724.svg
```
که هدر اصلی سایت در `app/component/Logo.js` از آن استفاده می‌کند.
**دو نکته که ساده‌ترین راه‌حل را می‌شکنند:**
اول، رنگ stroke لوگو `#5559CE` است و پس‌زمینهٔ هدر `#5559C2`. اگر همان svg را
مستقیم بگذارید، لوگو تقریباً نامرئی می‌شود. باید نسخهٔ روشن رندر شود.
دوم، `SiteLogo` از قبل همین کار را می‌کند: هم `city.logo_url` را در نظر می‌گیرد و
هم `stroke` را پارامتری گرفته. مسیرش:
```
components/doctor/poster/SiteLogo.js
```
## معیار پذیرش
- ✅ موفق: `/specialties/%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4`
کد `200` بدهد و تیتر «متخصص جراح گوارش در …» را نشان دهد.
- ✅ موفق: `/specialties/gastroenterology` همچنان `200` — اسلاگ لاتین نشکند.
- ✅ موفق: صفحهٔ مقاله دیگر «مخصوص شهر:» ندارد، ولی نویسنده و تاریخ سر جایشان‌اند.
- ✅ موفق: هر تگ مقاله لینک به `/blogs?tag=<نام تگ>` است و آن صفحه همان فیلتر را اعمال می‌کند.
- ✅ موفق: HTML صفحهٔ اصلی هیچ‌کدام از «تخصص‌های پرمراجعه در»، «پزشکان <شهر>» را ندارد.
- ❌ خطا: اسلاگ ناموجود — چه فارسی چه لاتین — همچنان `404` بدهد، نه `200` یا `500`.
- ❌ خطا: مقاله‌ای بدون تگ، بلوک تگ را اصلاً رندر نکند.
- ⚠️ مرزی: اسلاگ فارسی که با `%` شروع نمی‌شود ولی حرف فارسی خام دارد هم کار کند.
- ⚠️ مرزی: تگ حاوی فاصله یا `&` باید در URL درست encode شود.
- ⚠️ مرزی: عکس مطالب مرتبط با تیتر بسیار بلند نباید باریک شود؛ نسبت ابعاد ثابت بماند.
- ✅ موفق: صفحهٔ `/about-us` بندی دارد که پزشک را به `https://clinic-pro.ir/` می‌فرستد،
و آن آدرس لینک واقعی است نه متن ساده.
- ✅ موفق: لینک عضویت پزشک `target="_blank"` و `rel="noopener noreferrer"` دارد.
- ✅ موفق: هیچ ارجاعی به `logo-2.png` در کد نماند و لوگوی هدر «درباره ما» روی
پس‌زمینهٔ بنفش دیده شود.
- ❌ خطا: نام سایت در بند تازه هاردکد نشود؛ همان `siteName` که بقیهٔ صفحه استفاده می‌کند.
- ⚠️ مرزی: شهری که `logo_url` دارد باید لوگوی خودش را در هدر ببیند، نه لوگوی پیش‌فرض.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `components/home/index.js` | چیدمان صفحهٔ اصلی — `CityHighlights` از اینجا حذف شود |
| `components/home/CityHighlights.js` | کامپوننتی که باید برداشته شود |
| `components/home/CityHighlights.test.js` | تستش هم با آن می‌رود |
| `lib/listingIntro.js` | `buildCityIntro` و `CITY_INTROS` — فقط مصرف‌کننده‌شان صفحهٔ اصلی بود |
| `lib/cityIntro.test.js` | تست‌های `buildCityIntro` |
| `app/specialties/[slug]/page.js` | ۴۰۴ اسلاگ فارسی |
| `app/blog/[slug]/page.js` | همان باگ، پنهان چون با uuid تست می‌شود |
| `components/blog/head/index.js` | برچسب «مخصوص شهر» و چیپ‌های تگ |
| `components/blogs/index.js` | خوانندهٔ `?tag=` — تأیید می‌کند لینک تگ کار می‌کند |
| `components/blog/relatedContent/Item.js` | عکس دفرمه |
| `components/aboutUs/index.js` | متن «درباره ما» — بند تازهٔ پزشک اینجا اضافه شود |
| `components/aboutUs/Head.js` | هدر بنفش با لوگوی غلط |
| `components/doctor/poster/SiteLogo.js` | لوگوی درست، `stroke` پارامتری، پشتیبانی `logo_url` |
| `app/component/Logo.js` | لوگوی هدر اصلی — مرجع اینکه لوگوی واقعی کدام است |
| `public/assets/images/logo-2.png` | فایل یتیم — بعد از اصلاح هیچ مصرف‌کننده‌ای ندارد |
## وضعیت فعلی
### صفحهٔ اصلی
```jsx
// components/home/index.js
<div className="padding-responsive z-20 min-h-screen py-[120px] flex items-center justify-center flex-col gap-[40px]">
<TextHeader />
<SearchBar />
<FrequentSearches />
<CityHighlights />
</div>
```
### صفحهٔ تخصص — دو مصرف متفاوت از یک param
```jsx
// app/specialties/[slug]/page.js:18
const findSpecialty = (slug) =>
specialtiesData.find((item) => item.slug === slug && item.status === 1) ?? null;
// :45 generateMetadata — اینجا decode شده می‌رسد
const { slug } = await params;
const specialty = findSpecialty(slug);
if (!specialty) notFound();
// :78 بدنهٔ صفحه — اینجا encode شده می‌رسد
const { slug } = await params;
const specialty = findSpecialty(slug);
if (!specialty) notFound();
```
### برچسب شهر
```jsx
// components/blog/head/index.js:49
{cityName && (
<div className="flex items-center justify-start gap-1">
<p className="text-[#9B9B9B] text-[11px] md:text-[13px] lg:text-[14px] font-normal">
مخصوص شهر:
</p>
<p className="text-[#9B9B9B] text-[11px] md:text-[14px] lg:text-[16px] font-medium">
{cityName}
</p>
</div>
)}
```
### تگ‌های بدون لینک
```jsx
// components/blog/head/index.js:90
{/* فهرست کامل تگ‌ها — تا این تغییر فقط تگ اول در بردکرامب دیده می‌شد.
صفحهٔ فیلتر تگ وجود ندارد، پس چیپ‌ها بدون لینک رندر می‌شوند. */}
{tags.length > 0 && (
<ul className="flex flex-wrap items-center justify-start gap-2 mt-[12px] md:mt-[16px]">
{tags.map((tag, idx) => (
<li
key={`${tag}-${idx}`}
className="bg-[#F5F5F5] text-[#525252] rounded-full px-[12px] py-[4px] text-[11px] md:text-[13px] font-medium"
>
{tag}
</li>
))}
</ul>
)}
```
ولی فیلتر تگ از قبل هست:
```js
// components/blogs/index.js:16
const selectedTag = searchParams.get("tag") || null;
// :32
params.tag = selectedTag;
```
### عکس مطالب مرتبط
```jsx
// components/blog/relatedContent/Item.js
<li className="flex w-full relative items-start justify-start gap-[8px] py-[12px]">
<CustomLoading width={80} height={72} loading={loading}>
<Link href={`/blog/${data.uuid}`}>
<img
className=" w-[64px] sm:w-[73px] md:w-[85px] lg:w-[97px] h-[56px] sm:h-[64px] md:h-[75px] lg:h-[88px] rounded-[8px] overflow-hidden object-cover "
src={cover}
alt={data.title || "article"}
/>
</Link>
</CustomLoading>
```
### هدر «درباره ما»
```jsx
// components/aboutUs/Head.js
<div className="bg-[#5559C2] rounded-none md:rounded-lg w-full py-[24px] flex flex-col items-center justify-center gap-[13px] md:gap-[16px]">
<Image
src="/assets/images/logo-2.png"
height={46}
width={46}
alt="logo"
/>
<h1 className="...">درباره {siteName}</h1>
{slogan && <p className="...">{slogan}</p>}
</div>
```
`Head` الان فقط `siteName` و `slogan` می‌گیرد؛ `matchedCity` به آن پاس نمی‌شود.
### متن «درباره ما»
```jsx
// components/aboutUs/index.js — دو بند، هر دو از دید بیمار
<p>{siteName} یک سامانهی آنلاین نوبتدهی پزشکی است که </p>
<p>در {siteName} میتوانید بر اساس تخصص، نام پزشک یا مرکز درمانی جستجو کنید </p>
```
## وظایف
### ۱. برداشتن بخش شهرمحور صفحهٔ اصلی
- `<CityHighlights />` و import‌ش از `components/home/index.js` حذف شود.
- فایل `components/home/CityHighlights.js` و تستش پاک شوند.
- `buildCityIntro` و `CITY_INTROS` در `lib/listingIntro.js` تنها مصرف‌کننده‌شان همین
کامپوننت بود؛ با آن پاک شوند. تست‌های مربوطه در `lib/cityIntro.test.js` هم بروند.
**کد مرده نگه ندارید** — همین اشتباه قبلاً با `buildDoctorsIntro` تکرار شد.
- `buildClinicsIntro` و `CLINIC_INTROS` بمانند؛ `/clinics` هنوز مصرفشان می‌کند.
**نحوه تست:**
```bash
grep -rn "CityHighlights\|buildCityIntro" app components lib # باید خالی باشد
npm run test && npm run lint
NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build && npm run start &
curl -s -H "Host: yasuj-nobat.ir" http://localhost:3000/ | grep -cE "تخصص‌های پرمراجعه|پزشکان یاسوج" # باید 0
```
### ۲. رفع ۴۰۴ اسلاگ فارسی
یک helper مشترک بسازید — این باگ در دو صفحه هست و فردا در صفحهٔ سوم هم می‌آید:
```js
// lib/routeParams.js
/**
* segment مسیر را به شکل decode‌شده برمی‌گرداند.
*
* Next در `generateMetadata` مقدار decode‌شده می‌دهد ولی در بدنهٔ همان صفحه مقدار
* خام؛ روی اسلاگ‌های فارسی این یعنی متادیتا درست ساخته می‌شود و بلافاصله بعدش
* صفحه ۴۰۴ می‌دهد. اثبات‌شده روی build واقعی، نه فرض.
*
* decode شکست‌خورده (درصدِ ناقص در ورودی) نباید صفحه را بشکند؛ همان مقدار خام
* برمی‌گردد تا نتیجه‌اش ۴۰۴ طبیعی باشد نه ۵۰۰.
*/
export function decodeRouteParam(value) {
if (typeof value !== "string" || !value.includes("%")) return value;
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
```
سپس در هر دو صفحه، بلافاصله بعد از `await params`:
```js
const { slug: rawSlug } = await params;
const slug = decodeRouteParam(rawSlug);
```
در `app/specialties/[slug]/page.js` هر دو جا (`generateMetadata` و بدنه) و در
`app/blog/[slug]/page.js` هم هر دو جا.
**مواظب `slug` که به کامپوننت پاس می‌شود باشید:** در صفحهٔ تخصص، `slug` به
`SpecialtyDetailPage` می‌رود و آنجا در breadcrumb به `/specialties/${slug}` تبدیل
می‌شود. مقدار decode‌شده آنجا درست است چون `next/link` خودش encode می‌کند.
**نحوه تست:**
```bash
NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build && npm run start &
for u in "/specialties/%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4" \
"/specialties/gastroenterology" "/specialties/does-not-exist"; do
echo -n "$u"; curl -s -o /dev/null -w "%{http_code}\n" -H "Host: yasuj-nobat.ir" "http://localhost:3000$u"
done
# انتظار: 200، 200، 404
```
به‌علاوه unit test برای `decodeRouteParam` در `lib/routeParams.test.js`: مقدار
encode‌شده، مقدار خام فارسی، مقدار لاتین، رشتهٔ با `%` ناقص (نباید throw کند)،
ورودی `undefined`.
### ۳. حذف برچسب «مخصوص شهر»
کل بلوک `{cityName && (...)}` در `components/blog/head/index.js` برداشته شود.
اگر `cityName` بعد از آن هیچ مصرفی ندارد، محاسبه‌اش هم پاک شود تا متغیر بلااستفاده نماند.
منطق scope شهریِ مقاله‌ها (`domainScopeCityId` و `city_id` در fetch) **دست نخورد**
کاربر فقط همین جمله را خواست.
**نحوه تست:** `curl` صفحهٔ مقاله و `grep -c "مخصوص شهر"` که باید صفر باشد، در حالی که
`grep -c "نویسنده:"` همچنان یک است.
### ۴. لینک‌کردن تگ‌ها
هر چیپ به `/blogs?tag=<نام>` لینک شود. `next/link` خودش encode می‌کند، ولی چون تگ
ممکن است فاصله یا `&` داشته باشد، از شکل شیئی استفاده کنید تا مطمئن شوید:
```jsx
<Link href={{ pathname: "/blogs", query: { tag } }} className="...">
{tag}
</Link>
```
کامنت کهنهٔ بالای بلوک («صفحهٔ فیلتر تگ وجود ندارد») حذف یا اصلاح شود؛ همان کامنت
باعث شد این قابلیت جا بماند.
استایل چیپ عوض نشود، فقط `hover` مناسب اضافه شود چون حالا کلیک‌شدنی است.
**نحوه تست:** تست کامپوننتی روی `components/blog/head` — مقاله با دو تگ، دو
`role="link"` با `href` شامل `/blogs?tag=`؛ مقاله بدون تگ، هیچ لیستی رندر نشود.
سپس دستی: کلیک روی تگ باید به `/blogs?tag=…` برود و همان فیلتر اعمال شود.
### ۵. رفع دفرمگی عکس مطالب مرتبط
به ظرف عکس `shrink-0` اضافه شود تا در flex فشرده نشود:
```jsx
<CustomLoading width={80} height={72} loading={loading}>
<Link href={`/blog/${data.uuid}`} className="block shrink-0">
<img className="... shrink-0 ..." ... />
</Link>
</CustomLoading>
```
اگر `CustomLoading` خودش ظرف flex می‌سازد، `shrink-0` باید روی همان بیرونی‌ترین
عنصرِ داخل `<li>` بنشیند — اول ساختار رندرشده را ببینید، بعد کلاس را جای درست بگذارید.
**نحوه تست:** تست کامپوننتی که کلاس `shrink-0` روی ظرف عکس هست. به‌علاوه بررسی
چشمی در مرورگر با یک تیتر بسیار بلند — jsdom چیدمان را اندازه نمی‌گیرد، پس این مورد
را **صادقانه به‌عنوان «بررسی چشمی» گزارش کنید**، نه به‌عنوان تست خودکار.
### ۶. بند عضویت پزشک در «درباره ما»
یک بند سوم بعد از دو بند فعلی در `components/aboutUs/index.js` اضافه شود. متن
پیشنهادی:
```jsx
<p>
اگر پزشک هستید و میخواهید در {siteName} نوبتدهی آنلاین داشته باشید، ثبتنام
از طریق سامانهی کلینیکپرو انجام میشود. کافی است به{" "}
<a
href="https://clinic-pro.ir/"
target="_blank"
rel="noopener noreferrer"
className="text-[#5559C2] font-medium underline underline-offset-4"
>
clinic-pro.ir
</a>{" "}
مراجعه کنید و حساب مطب خود را بسازید. پس از تأیید، پروفایل و برنامهی
نوبتدهی شما روی {siteName} نمایش داده میشود.
</p>
```
قواعد:
- نام سایت هاردکد نشود؛ همان `siteName` بالای فایل استفاده شود.
- لینک خارجی است، پس `next/link` لازم نیست؛ `<a>` با `target="_blank"` و
`rel="noopener noreferrer"` درست است.
- استایل بند از بقیه جدا نشود؛ همان ظرف `flex flex-col gap-[16px]` را می‌گیرد.
**نحوه تست:**
```bash
NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build && npm run start &
curl -s -H "Host: yasuj-nobat.ir" http://localhost:3000/about-us | grep -c "clinic-pro.ir" # باید ≥ 1
```
به‌علاوه تست کامپوننتی: لینکی با `href="https://clinic-pro.ir/"` رندر شود و
`rel` شامل `noopener` باشد.
### ۷. اصلاح لوگوی هدر «درباره ما»
`<Image src="/assets/images/logo-2.png" />` با `SiteLogo` جایگزین شود:
```jsx
<SiteLogo city={matchedCity} stroke="#FAFAFA" className="w-[46px] h-[46px]" />
```
- `matchedCity` باید از `components/aboutUs/index.js` به `Head` پاس شود؛ الان
فقط `siteName` و `slogan` می‌رود.
- `stroke="#FAFAFA"` لازم است چون رنگ پیش‌فرض روی پس‌زمینهٔ بنفش گم می‌شود.
- `crossOrigin="anonymous"` داخل `SiteLogo` برای حالت poster گذاشته شده و اینجا
ضرری ندارد؛ دست نزنید.
- اگر `SiteLogo` جای بهتری لازم دارد چون دیگر فقط مال poster نیست، جابه‌جایی‌اش
به `components/common/` قابل قبول است — ولی آن‌وقت هر دو مصرف‌کننده باید
به‌روز شوند و تست‌ها سبز بمانند.
- بعد از اصلاح، `public/assets/images/logo-2.png` هیچ مصرف‌کننده‌ای ندارد و پاک
می‌شود. **اول با grep ثابت کنید یتیم است، بعد پاک کنید.**
**نحوه تست:**
```bash
grep -rn "logo-2" app components public --include="*.js" --include="*.json" # باید خالی باشد
```
به‌علاوه تست کامپوننتی روی `components/aboutUs/Head`: شهر بدون `logo_url` باید
svg درون‌خطی بدهد، شهر با `logo_url` باید `<img>` با همان آدرس بدهد.
بررسی چشمی هم لازم است — دیده‌شدن لوگو روی بنفش را jsdom نمی‌سنجد. آن را
**صادقانه به‌عنوان «بررسی چشمی» گزارش کنید**.
## نکات مهم
- **۴۰۴ اسلاگ فارسی، مهم‌ترین بخش این تسک است.** اگر فقط صفحهٔ تخصص درست شود و
صفحهٔ مقاله جا بماند، همان باگ با اولین مقالهٔ فارسی‌اسلاگ برمی‌گردد. هر دو صفحه
در همین تسک اصلاح شوند.
- **دلیل helper مشترک به‌جای دو تکه کد:** دو مصرف‌کنندهٔ فعلی و یک الگوی تکرارشونده.
این abstraction «برای آینده» نیست؛ همین حالا دو جا لازم است.
- **حذف کد مرده جزو کار است.** `buildCityIntro` بعد از برداشتن `CityHighlights`
بلااستفاده می‌شود. نگه‌داشتنش همان وضعیتی را می‌سازد که `buildDoctorsIntro` ساخته
بود: تابعی که تست دارد ولی هیچ‌جا رندر نمی‌شود.
- **سنجهٔ شباهت صفحهٔ اصلی برمی‌گردد به حدود ۹۰٪.** تسک قبلی آن را به ۷۱٫۵٪ رسانده
بود. این عقب‌گرد خواستهٔ کاربر است و باید در گزارش پایانی صریح ذکر شود، نه بی‌صدا.
مشکل `Duplicate, Google chose different canonical` روی صفحهٔ اصلی برمی‌گردد.
- **دو مورد «درباره ما» مستقل از پنج مورد قبلی‌اند.** اگر یکی از آن‌ها گیر کرد،
بقیه را کامل کنید و همان یکی را صریح گزارش دهید.
- **لوگو مسئلهٔ کنتراست است، نه فقط عوض‌کردن فایل.** stroke لوگو `#5559CE` و
پس‌زمینه `#5559C2` است. اگر بدون `stroke` روشن جایگزین کنید، تست‌ها سبز
می‌شوند ولی کاربر لوگو را نمی‌بیند.
- خط پایهٔ ریپو پیش از این تسک: `npm run test` چهار شکستِ ازقبل‌موجود در
`lib/lib.test.js` و `lib/getStateInfo.test.js`؛ `npm run lint` سه خطا در فایل‌های
بی‌ربط. این‌ها رگرسیون نیستند.
- `npm run start` هشدار `"next start" does not work with "output: standalone"`
می‌دهد. صفحات رندر می‌شوند و برای این تست‌ها کافی است، ولی اگر رفتار عجیبی دیدید
اول همین را در نظر بگیرید.
@@ -1,303 +0,0 @@
# رزرو آنلاین منبع‌محور در جریان نوبت‌گیری
## پروژه
`nobat724_front` (سایت عمومی).
پرامپت همتا در backend: `clinicpro/.claude/prompt/public-resource-booking-api.md`.
**آن باید اول اجرا و merge شود** — این پرامپت سه اندپوینتی را مصرف می‌کند که آنجا ساخته می‌شوند.
## زمینه
در کلینیک‌پرو، «منبع» هر چیزی است که ممکن است اشغال باشد: پزشک، اپراتور، دستگاه، اتاق، یونیت.
هر منبع تقویم مستقل خودش را دارد و سرویس‌هایی که ارائه می‌دهد، با مدت و قیمتِ اختصاصیِ همان منبع،
روی خودش تعریف می‌شوند. مالک کلینیک با توگل «نمایش در نوبت‌دهی آنلاین» روی هر سرویس تعیین می‌کند
که آن سرویس در سایت دیده شود یا نه.
جریان فعلیِ نوبت‌گیری سایت فقط پزشک‌محور است: محل → (در حالت سرویسی) سرویس → روز → ساعت.
## مشکل / هدف
بیمار نمی‌تواند روی یک دستگاه یا اتاقِ مشخص نوبت بگیرد. هدف: افزودن یک مرحلهٔ «انتخاب منبع» به
همان جریان، بدون شکستن مسیر فعلی.
**تصمیم دامنه (تأییدشده):**
- نقطهٔ ورود فقط `/appointment/[doctorId]` است. صفحهٔ کلینیک در این تسک دست نمی‌خورد.
- فقط منابعِ همان پزشک نمایش داده می‌شوند (backend خودش فیلتر می‌کند؛ سایت فیلتر اضافه نمی‌زند).
- اگر پزشک در آن محل هیچ منبعی نداشته باشد، **هیچ تغییری در تجربهٔ فعلی دیده نمی‌شود**
مرحلهٔ منبع اصلاً رندر نمی‌شود.
## معیار پذیرش
- ✅ موفق:
- پزشکی که در محل انتخاب‌شده منبعِ قابل رزرو دارد: بعد از تأیید محل، مرحلهٔ «انتخاب منبع»
دیده می‌شود با کارت «نوبت با پزشک» به‌علاوهٔ یک کارت به ازای هر منبع.
- انتخاب یک منبع → فهرست سرویس‌های **همان منبع** با مدت و قیمتِ همان منبع → تقویم → ساعت‌ها
از `appointment-resource-slots` → ثبت نوبت با `resource_uuid` در payload → پاسخ ۲۰۱ و رفتن
به مرحلهٔ پرداخت، مثل جریان فعلی.
- انتخاب «نوبت با پزشک» → دقیقاً جریان امروز، بدون هیچ تفاوتی.
- ❌ خطا:
- خطای شبکه یا ۴۲۲ روی `getBookingResources` → مرحلهٔ منبع رندر نشود و جریان پزشک‌محور ادامه
یابد. صفحه نباید سفید شود یا در حالت loading گیر کند.
- ثبت نوبت با ۴۰۹ (منبع در آن لحظه پر شد) → پیام فارسی به کاربر و برگشت به مرحلهٔ ساعت،
نه خطای خام.
- ⚠️ مرزی:
- پزشک بدون منبع → صفر تغییر در UI فعلی (این را صریح تست کن، نه فرض).
- منبعی که در روز انتخاب‌شده شیفت ندارد → پیام «در حال حاضر، نوبتی برای این روز موجود نمی‌باشد.»
مثل امروز، نه فهرست خالی و بی‌توضیح.
- تعویض محل بعد از انتخاب منبع → منبع و سرویس و ساعتِ انتخاب‌شده باید باطل شوند. امروز
`changeLocation` همین کار را برای سرویس و اسلات می‌کند؛ منبع هم باید به آن اضافه شود.
- تعویض منبع بعد از انتخاب سرویس → سرویس‌ها باطل شوند (سرویسِ منبع الف روی منبع ب معتبر نیست
و backend با ۴۲۲ رد می‌کند).
- منبعِ با ظرفیت بیش از ۱: ساعت‌ها ممکن است با نوبتِ موجود هم‌پوشان باشند — این درست است و
سایت نباید فیلتر اضافه بزند.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `services/response.js` | wrapper همهٔ فراخوانی‌های API |
| `components/appointment/index.js` | state جریان نوبت: محل، سرویس، اسلات |
| `components/appointment/Container.js` | تصمیمِ اینکه کدام مرحله رندر شود |
| `components/appointment/location/LocationSelect.js` | الگوی مرجعِ ظاهرِ کارت انتخاب |
| `components/appointment/service/index.js` | مرحلهٔ انتخاب سرویس |
| `components/appointment/date/index.js`, `date/hour/index.js` | مرحلهٔ روز و ساعت |
| `app/component/date/dateTime/index.js` | فراخوانیِ اسلات‌ها |
| `app/component/date/datePicker/index.js` | تقویم و روزهای غیرفعال ماه |
| `components/appointment/detail/SubmitData.js` | ساخت payload و `postAppointment` |
| `lib/appointmentSlots.js` | `adaptServiceSlots` — همان شکل `start_times` |
## وضعیت فعلی
انتخاب مرحله در `Container.js` یک زنجیرهٔ `if/else` است:
```jsx
// components/appointment/Container.js
let dateStep;
if (bookingLocations.length === 0) {
dateStep = (
<p className="w-full max-w-[520px] mx-auto py-[40px] text-center text-[14px] text-[#7A7A7A]">
نوبتدهی آنلاین برای این پزشک فعال نیست.
</p>
);
} else if (multiLocation && !locationConfirmed) {
dateStep = (
<LocationSelect locations={bookingLocations} selected={selectedLocation} onSelect={changeLocation} />
);
} else if (serviceMode && selectedServiceUuids.length === 0) {
dateStep = (
<ServiceSelect services={bookingServices} onContinue={(uuids) => setSelectedServiceUuids(uuids)} />
);
} else {
dateStep = ( <Date ... /> );
}
```
باطل‌سازی با تعویض محل:
```jsx
// components/appointment/index.js
const changeLocation = (location) => {
setSelectedLocation(location);
setLocationConfirmed(true);
setSelectedServiceUuids([]);
setSelectedSlot(null);
setSelectedDate(null);
};
```
اسلات‌ها امروز فقط دو حالت دارند:
```jsx
// app/component/date/dateTime/index.js
const req = serviceMode
? request
.getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid)
.then(adaptServiceSlots)
: request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots);
```
و روزهای غیرفعال تقویم فقط پزشک‌محورند:
```jsx
// app/component/date/datePicker/index.js
request
.getMonthAvailability(doctorUuid, year, month, clinicUuid)
```
payload ثبت نوبت هنوز `resource_uuid` ندارد:
```jsx
// components/appointment/detail/SubmitData.js
const appointmentPayload = {
doctor_uuid: doctor.uuid,
clinic_uuid: clinicUuid,
slot_start: selectedSlot.start,
slot_end: selectedSlot.end,
// ...
...(selectedServiceUuids?.length ? { service_item_uuids: selectedServiceUuids } : {}),
```
## وظایف
### ۱. سه wrapper جدید در `services/response.js`
کنار همتاهای پزشک‌محور، با همان سبک و همان `removeTokenHead` (این اندپوینت‌ها عمومی‌اند):
```js
getBookingResources: (doctor_uuid, clinic_uuid = null) =>
api.get(
`api/v1/appointment-booking-resources/${doctor_uuid}${clinicQuery(clinic_uuid, "?")}`,
removeTokenHead
),
getResourceSlots: (resource_uuid, date, serviceItemUuids = []) =>
api.get(
`api/v1/appointment-resource-slots?resource_uuid=${resource_uuid}&date=${date}` +
serviceItemUuids
.map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`)
.join(""),
removeTokenHead
),
getResourceMonthAvailability: (resource_uuid, year, month, serviceItemUuids = []) =>
api.get(`api/v1/appointment-resource-month-availability/${resource_uuid}`, {
params: { year, month },
...removeTokenHead,
}),
```
نکته: `service_item_uuids[]` آرایه است و `params` اکسیوس آن را با `[]` سریالایز نمی‌کند — به همین
دلیل `getServiceSlots` موجود هم دستی رشته می‌سازد. برای `month-availability` هم همان کار را بکن
(رشتهٔ query دستی)، وگرنه backend سرویس‌ها را نمی‌بیند و مدت را نمی‌تواند حساب کند.
**نحوه تست:** در devtools، هر سه فراخوانی باید بدون هدر `Authorization` بروند و ۲۰۰ بگیرند.
### ۲. state منبع در `components/appointment/index.js`
```jsx
const [resources, setResources] = useState([]);
const [selectedResource, setSelectedResource] = useState(null); // null = نوبت با پزشک
const [resourceConfirmed, setResourceConfirmed] = useState(false);
```
- با تغییر `selectedLocation`، منابع همان محل گرفته شوند:
`request.getBookingResources(doctor.uuid, selectedLocation?.clinic_uuid ?? null)`.
خطا → `setResources([])` (جریان فعلی ادامه یابد؛ سکوت عمدی است، نه فراموشی — کامنتش را بنویس).
- `changeLocation` باید `selectedResource`، `resourceConfirmed` و `resources` را هم باطل کند.
- تابع `changeResource(resource)` که `selectedServiceUuids`، `selectedSlot`، `selectedDate` را
صفر می‌کند و `resourceConfirmed = true` می‌گذارد.
- وقتی `resources.length === 0`، مقدار `resourceConfirmed` باید از ابتدا `true` باشد تا مرحله
اصلاً رندر نشود.
- سرویس‌هایی که به `ServiceSelect` می‌روند: اگر منبع انتخاب شده، `selectedResource.services`؛
وگرنه همان `selectedLocation?.services` امروز.
- وقتی منبع انتخاب شده، جریان **همیشه** سرویسی است (منبع اسلاتِ ثابت ندارد)، حتی اگر
`booking_mode` آن محل `slot` باشد.
**نحوه تست:** با `09390039833 / 09390039833` در پنل `/admin` یک منبع بساز، به آن یک سرویس با
«نمایش در نوبت‌دهی آنلاین» روشن وصل کن، سپس صفحهٔ نوبتِ همان پزشک را در
`http://yazd-nobat.localhost:3000/appointment/<doctor-uuid>` باز کن.
بعد همان سرویس را در پنل خاموش کن و رفرش بزن — مرحلهٔ منبع باید ناپدید شود.
### ۳. کامپوننت `components/appointment/resource/index.js`
کارت‌های انتخاب، **دقیقاً با ظاهر و کلاس‌های `LocationSelect.js` و `ServiceSelect`** — طراحی
جدید نساز، رنگ جدید معرفی نکن. رنگ‌های موجود: `#5559CE` (اصلی)، `#3B3B3B` (متن)، `#7A7A7A`
(متن ثانویه)، `#EFEFEF` (خط).
محتوای هر کارت:
- کارت اول همیشه: «نوبت با پزشک» با زیرعنوانِ کوتاه — انتخابش یعنی `changeResource(null)`.
- هر منبع: `name` به‌عنوان عنوان، `type.name` به‌عنوان برچسبِ نوع، تعداد سرویس‌ها و کوتاه‌ترین
مدت به‌عنوان زیرنویس.
- عنوان مرحله: «۱. انتخاب نوع نوبت» — و در نتیجه سرویس «۲.» و ساعت «۳.» می‌شود. شماره‌های
موجود در `ServiceSelect` و `Hour` باید هماهنگ شوند، وگرنه دو مرحله هر دو «۱.» می‌شوند.
در `Container.js` این مرحله **بعد از** تأیید محل و **قبل از** انتخاب سرویس بنشیند:
```jsx
} else if (resources.length > 0 && !resourceConfirmed) {
dateStep = (
<ResourceSelect resources={resources} onSelect={changeResource} />
);
} else if (serviceMode && selectedServiceUuids.length === 0) {
```
و در مرحلهٔ روز، دکمهٔ «← تغییر نوع نوبت» کنار «تغییر محل» و «تغییر سرویس» موجود در
`components/appointment/date/index.js` اضافه شود (همان سبک، همان کلاس).
**نحوه تست:** هر سه دکمهٔ بازگشت را بزن و مطمئن شو انتخاب‌های پایین‌دستی پاک می‌شوند — مثلاً
بعد از «تغییر نوع نوبت»، سرویس قبلی نباید هنوز انتخاب باشد.
### ۴. اسلات‌ها و تقویمِ منبع
در `app/component/date/dateTime/index.js` یک شاخهٔ سوم:
```jsx
const req = resourceUuid
? request
.getResourceSlots(resourceUuid, dateStr, selectedServiceUuids)
.then(adaptServiceSlots)
: serviceMode
? request.getServiceSlots(...).then(adaptServiceSlots)
: request.getAppointmentSlots(...).then(adaptSlots);
```
`adaptServiceSlots` بدون تغییر کار می‌کند: پاسخ منبع هم `start_times` با همان شکل
`{start, end, start_time, end_time}` می‌دهد. **`lib/appointmentSlots.js` را دست نزن.**
`resourceUuid` باید از `Container` تا `DateTime` رد شود:
`Date``Hour``DateTime`. همان زنجیره‌ای که `clinicUuid` امروز طی می‌کند.
در `app/component/date/datePicker/index.js`، وقتی `resourceUuid` داده شده،
`getResourceMonthAvailability(resourceUuid, year, month, selectedServiceUuids)` جای
`getMonthAvailability` بنشیند. کشِ `loadedMonths` باید با تغییر `resourceUuid` هم پاک شود —
دقیقاً همان `useEffect`ی که امروز برای `clinicUuid` هست:
```jsx
useEffect(() => {
loadedMonths.current = new Set();
setDisabledSet(new Set());
setOnlineEnabled(true);
setAutoSelected(false);
}, [clinicUuid]); // ← resourceUuid هم به آرایهٔ وابستگی اضافه شود
```
**نحوه تست:** یک منبع با شیفتِ فقط دوشنبه بساز. در تقویم سایت، همهٔ روزها جز دوشنبه‌ها باید
غیرفعال باشند. روی یک دوشنبه بزن و ساعت‌ها را ببین.
### ۵. `resource_uuid` در payload ثبت نوبت
در `components/appointment/detail/SubmitData.js`:
```jsx
// نوبتِ منبع‌محور: backend مدت و slot_end را از زنجیرهٔ حلِ همین منبع بازمحاسبه
// می‌کند، پس عددِ کلاینت فقط پیشنهاد است.
...(resourceUuid ? { resource_uuid: resourceUuid } : {}),
```
`resourceUuid` باید از `Container` به `Detail` و از آنجا به `SubmitData` برسد — همان مسیری که
`selectedServiceUuids` و `clinicUuid` امروز طی می‌کنند.
مدیریت ۴۰۹: پاسخ backend در این حالت `{ success: false, errors: [{ code, message, field: 'resource_uuid' }] }`
است. پیام فارسیِ همان errors نمایش داده شود و کاربر به مرحلهٔ ساعت برگردد.
**نحوه تست (سناریوی کامل):**
۱. در پنل، منبع + سرویسِ روشن بساز.
۲. در سایت نوبت بگیر تا مرحلهٔ پرداخت.
۳. در پنل `/admin/appointments` نوبت را ببین: باید فیلد منبع را نشان دهد.
۴. برای تست ۴۰۹: همان بازه را از پنل روی همان منبع رزرو کن، بعد در تبِ سایت ثبت را بزن.
## نکات مهم
- **صفر تغییر برای پزشک بدون منبع.** هر تغییری که مسیر فعلی را حتی یک کلیک عوض کند، اشتباه است.
این را با یک پزشکِ بدون منبع دستی تست کن، نه با خواندن کد.
- **طراحی جدید ممنوع.** مرحلهٔ منبع باید از کامپوننت‌ها، کلاس‌ها و رنگ‌های موجود ساخته شود.
`LocationSelect.js` مرجع ظاهر است.
- RTL، فارسی، تقویم جلالی — مثل بقیهٔ جریان. عدد مدت و قیمت با `toLocaleString("fa-IR")` مثل
`toToman()` موجود در `ServiceSelect`.
- **باطل‌سازیِ آبشاری** یک قاعده است نه سه‌تا استثنا: محل → منبع → سرویس → روز → ساعت. تغییر هر
سطح، همهٔ سطوح پایین‌ترش را پاک می‌کند. اگر این را در یک تابع متمرکز کنی، جای سه `setState`
پراکنده، خطای بعدی خودش را نشان می‌دهد.
- **`durations[]` نفرست.** backend در مسیر عمومی آن را نمی‌پذیرد؛ override مدت ابزار پنل است.
- **cross-repo:** تغییر شکل پاسخ در backend اینجا خطای build نمی‌دهد. بعد از هر تغییر در
`clinicpro`، این سه فراخوانی را دستی بررسی کن.
+33
View File
@@ -109,6 +109,39 @@ All public pages wrap content in `<Layout name="/path">` from `components/layout
- `data/state.json` — province/state data, joined to city via `province_id`
- `data/specialties.json` — medical specialties; items with `parent` field are sub-specialties shown in FrequentSearches
### Booking Modes (slot vs service)
The backend decides how a doctor is booked, **per location**: a doctor can be
slot-based in their own office and service-based in a clinic. The mode arrives as
`booking_mode` on each entry of `getBookingLocations`, so `components/appointment/index.js`
reads it off the *selected* location, never off the doctor.
| Mode | Flow | Slot source |
|---|---|---|
| `slot` | date → time | `getAppointmentSlots``adaptSlots` |
| `service` | **service → date → time** | `getServiceSlots``adaptServiceSlots` |
- `lib/appointmentSlots.js` is the split point. Both adapters return the same shape —
an array of sessions `{ start_time, end_time, label, slots }` — which
`app/component/date/dateTime/index.js` turns into tabs (`sessions.length > 1`).
- **Duration is server data.** `total_duration_minutes` comes from
`appointment-service-slots`; never sum `duration_minutes` in the front. The backend
formula is going to change to solo/additional minutes, and any parallel client
calculation will silently start showing a wrong number. `components/appointment/service/index.js`
keeps a client sum only as a labelled fallback with a `console.warn`.
- The service step runs *before* the date step, yet `total_duration_minutes` does not
depend on the date — the backend computes it before touching that day's shifts — so
the picker may ask for it using today's date even if today is closed.
- Shift boundaries are **not** derived in the front for service mode: a flat
`start_times` list cannot distinguish a break between shifts from a gap left by a
booked appointment. One session with the real range is returned instead. See the note
in `clinicpro/docs/api/appointment.md`.
- User panel: `service_items` and `service_total_minutes` come from
`GET /api/v1/appointments/user`. Slot-mode appointments have neither, so every read
is guarded — an unguarded `.map` crashes the card for all slot-mode appointments.
Backend reference: `clinicpro/docs/architecture/booking-modes.md`.
### Doctor & Clinic Slugs
Both use `uuid` as the URL slug: `/doctor/${doctor.uuid}` and `/clinic/${clinic.uuid}`.
+4 -1
View File
@@ -16,9 +16,12 @@ export function Providers({ children }) {
.catch(() => {});
}, []);
// `attribute="data-"` یک اتریبیوت با نام تحت‌اللفظی `data-` می‌ساخت (`<html data-="dark">`)،
// نه `data-theme`. هم `globals.css` و هم مستند پروژه از اول `data-theme` را فرض کرده‌اند،
// پس هیچ‌کدام هرگز اجرا نمی‌شدند.
return (
<ThemeProvider
attribute="data-"
attribute="data-theme"
defaultTheme="system"
enableSystem
>
+3 -12
View File
@@ -3,19 +3,10 @@ import Layout from "@/components/layout/StLayout";
import { getStateInfo } from "@/lib/getStateInfo";
export async function generateMetadata() {
const { matchedCity, matchedState, isRoot } = await getStateInfo();
const { matchedCity } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724";
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 title = `درباره ما | ${siteName}`;
const description = `آشنایی با ${siteName}، سیستم آنلاین نوبت‌دهی پزشکی. هدف ما ارائه خدمات سریع و کارآمد رزرو نوبت پزشکی برای همه مردم است.`;
const image = "/assets/images/og-image.png";
return {
title,
+9 -40
View File
@@ -1,7 +1,7 @@
import { cache } from "react";
import BlogPage from "@/components/blog";
import Layout from "@/components/layout/StLayout";
import { notFound, permanentRedirect } from "next/navigation";
import { notFound } from "next/navigation";
import { fetchReq } from "@/lib/req";
import { getStateInfo } from "@/lib/getStateInfo";
import { getBlogCanonicalOrigin } from "@/lib/getCanonicalUrl";
@@ -11,9 +11,7 @@ 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;
@@ -40,31 +38,12 @@ 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: rawSlug } = await params;
const slug = decodeRouteParam(rawSlug);
const { slug } = await params;
const { matchedCity } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724";
const blog = await resolveScopedBlog(slug);
const blog = await getScopedBlog(slug);
if (!blog) notFound();
try {
@@ -103,9 +82,7 @@ export async function generateMetadata({ params }) {
// دامنه سرو می‌شد و هرکدام خودش را canonical اعلام می‌کرد.
// فیلد canonical_url اختصاصی (مثلاً نسخهٔ شهریِ پایپ‌لاین) مقدم است.
const origin = getBlogCanonicalOrigin(extractEntityCityId(blog));
// slug اینجا decode شده است؛ canonical باید شکل درصدی را بدهد وگرنه روی
// اسلاگ فارسی یک URL با کاراکتر غیر ASCII منتشر می‌شود.
const canonical = blog.canonical_url || `${origin}/blog/${encodeURIComponent(slug)}`;
const canonical = blog.canonical_url || `${origin}/blog/${slug}`;
return {
title,
@@ -138,19 +115,11 @@ export async function generateMetadata({ params }) {
}
async function Blog({ params }) {
const { slug: rawSlug } = await params;
const slug = decodeRouteParam(rawSlug);
const slugPath = encodeURIComponent(slug);
const { slug } = await params;
const blog = normalizeBlog(await resolveScopedBlog(slug));
const blog = normalizeBlog(await getScopedBlog(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);
@@ -196,7 +165,7 @@ async function Blog({ params }) {
headline: blog.title,
...(articleDescription && { description: articleDescription }),
image: blogCover(blog),
mainEntityOfPage: { "@type": "WebPage", "@id": `${origin}/blog/${slugPath}` },
mainEntityOfPage: { "@type": "WebPage", "@id": `${origin}/blog/${slug}` },
...(blog.created && {
datePublished: new Date(blog.created * 1000).toISOString(),
}),
@@ -247,7 +216,7 @@ async function Blog({ params }) {
: []),
// آیتم آخر بدون `item` کل BreadcrumbList را نامعتبر می‌کند
{ "@type": "ListItem", position: categoryName ? 4 : 3, name: blog.title,
item: `${origin}/blog/${slugPath}` },
item: `${origin}/blog/${slug}` },
],
}
: null;
@@ -273,7 +242,7 @@ async function Blog({ params }) {
/>
)}
<Layout>
<BlogPage blog={blog} blogs={relatedBlogs} />
<BlogPage blog={blog} blogs={relatedBlogs} cityName={blogCity?.name} />
</Layout>
</>
);
-7
View File
@@ -6,8 +6,6 @@ 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;
@@ -81,11 +79,6 @@ export default async function Clinics({ searchParams }) {
matchedCity={matchedCity}
matchedState={matchedState}
origin={origin}
// قرینهٔ /doctors — buildClinicsIntro هم نوشته و تست شده بود ولی رندر نمی‌شد.
intro={buildClinicsIntro(
resolveCityDisplayName(isRoot ? null : matchedCity),
clinics?.meta?.totalRecords
)}
/>
</Layout>
);
+29 -32
View File
@@ -5,7 +5,6 @@ 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";
@@ -14,12 +13,6 @@ 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) =>
@@ -44,16 +37,20 @@ 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}>
{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>
)}
<Link href={`/doctor/${doctor?.uuid}`}>
<p className="text-[#3B3B3B] text-[14px] font-bold">
{doctor?.display_name || doctor?.name}
</p>
</Link>
</TextLoading>
<TextLoading loading={loading} width={120} height={15}>
<SpecialtyChips specialties={doctor?.specialties} />
<p className="text-[#616161] text-[14px] font-normal">
تخصص:
{doctor?.specialties?.map(
(item, idx) =>
`${item.name} ${doctor.specialties.length === idx + 1 ? "" : "|"} `
)}
</p>
</TextLoading>
<CustomLoading loading={loading} width={100} height={25}>
<div className="flex rounded items-center justify-start gap-2">
@@ -92,23 +89,23 @@ function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
: "نوبت‌دهی غیرفعال است"}
</p>
</CustomLoading>
{/* 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>
)}
{/* 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>
</li>
);
}
-74
View File
@@ -1,74 +0,0 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import ItemDoctor from "@/app/component/ItemDoctor";
import Loading from "@/app/doctors/loading";
const DOCTOR = {
id: 1,
uuid: "84b7ea9e-2fe1-4413-906d-7a5bf05e6f8f",
name: "محمدباقر جهانتاب",
specialties: [{ id: "13", name: "جراحی عمومی", parent_id: null }],
active: 1,
free_turn: "شنبه ۱۷:۴۵",
};
const noop = () => {};
describe("ItemDoctor — لینک پروفایل", () => {
it("پزشک با uuid لینک می‌گیرد", () => {
render(<ItemDoctor doctor={DOCTOR} setDoctors={noop} />);
const links = screen.getAllByRole("link");
expect(links.length).toBeGreaterThan(0);
for (const link of links) {
expect(link).toHaveAttribute("href", `/doctor/${DOCTOR.uuid}`);
}
});
it("کارت بدون uuid هیچ لینکی ندارد", () => {
render(<ItemDoctor doctor={{ id: 0 }} loading setDoctors={noop} />);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
it("رشتهٔ undefined هرگز در HTML نمی‌نشیند", () => {
// ریشهٔ خطای Not found (404) در Search Console: href="/doctor/undefined".
const { container } = render(
<ItemDoctor doctor={{ id: 0 }} loading setDoctors={noop} />
);
expect(container.innerHTML).not.toContain("undefined");
});
it("نام پزشک حتی بدون لینک نمایش داده می‌شود", () => {
render(<ItemDoctor doctor={{ id: 0, name: "بدون شناسه" }} setDoctors={noop} />);
expect(screen.getByText("بدون شناسه")).toBeInTheDocument();
});
it("display_name بر name مقدم است", () => {
render(
<ItemDoctor
doctor={{ ...DOCTOR, display_name: "دکتر جهانتاب" }}
setDoctors={noop}
/>
);
expect(screen.getByText("دکتر جهانتاب")).toBeInTheDocument();
});
});
describe("اسکلت بارگذاری /doctors", () => {
it("هیچ لینک doctor/undefined تولید نمی‌کند", () => {
const { container } = render(<Loading />);
expect(container.innerHTML).not.toContain("/doctor/undefined");
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
it("همچنان شش کارت اسکلت می‌سازد", () => {
const { container } = render(<Loading />);
expect(container.querySelectorAll("li")).toHaveLength(6);
});
});
-81
View File
@@ -1,81 +0,0 @@
"use client";
import { useState } from "react";
import { Popover } from "@mui/material";
import { splitSpecialties } from "@/lib/specialtyDisplay";
/**
* تخصص اصلی + شمارندهٔ بقیه در کارت پزشک.
*
* پیش از این همهٔ نام‌ها پشت‌سرهم چاپ می‌شدند و کارتِ پزشکِ شش‌تخصصی در موبایل
* چند برابر بقیه بلند می‌شد و شبکه را به‌هم می‌ریخت.
*
* جدا از ItemDoctor است تا مرز client فقط همین‌جا باشد؛ کارت از کامپوننت‌های
* سروری هم رندر می‌شود.
*/
function SpecialtyChips({ specialties }) {
const [anchorEl, setAnchorEl] = useState(null);
const { primary, rest } = splitSpecialties(specialties);
if (!primary) return null;
const open = Boolean(anchorEl);
// کارت داخل <Link> است؛ بدون این دو، هر کلیک روی شمارنده صفحهٔ پزشک را باز می‌کند.
const handleOpen = (event) => {
event.preventDefault();
event.stopPropagation();
setAnchorEl(event.currentTarget);
};
const handleClose = (event) => {
event?.preventDefault?.();
event?.stopPropagation?.();
setAnchorEl(null);
};
return (
<span className="flex items-center gap-1.5 min-w-0">
<span className="text-[#616161] text-[14px] font-normal truncate">
تخصص: {primary.name}
</span>
{rest.length > 0 && (
<>
<button
type="button"
onClick={handleOpen}
aria-label={`نمایش ${rest.length} تخصص دیگر`}
className="shrink-0 p-1 bg-[#F8F8FF] rounded-[4px] text-[#616161] text-[12px] font-medium leading-none"
>
+{rest.length}
</button>
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
<ul
dir="rtl"
className="p-3 flex flex-col gap-1.5 max-w-[260px] bg-[#FFF]"
>
{[primary, ...rest].map((item) => (
<li
key={item.id ?? item.name}
className="text-[#616161] text-[13px] font-normal"
>
{item.name}
</li>
))}
</ul>
</Popover>
</>
)}
</span>
);
}
export default SpecialtyChips;
-74
View File
@@ -1,74 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import Link from "next/link";
import SpecialtyChips from "@/app/component/SpecialtyChips";
const root = { id: "13", name: "جراحی عمومی", parent_id: null };
const gi = { id: "169", name: "جراح گوارش", parent_id: "13" };
const thyroid = { id: "168", name: "جراح تیروئید", parent_id: "13" };
describe("SpecialtyChips", () => {
it("تخصص اصلی را نشان می‌دهد و بقیه را می‌شمارد", () => {
render(<SpecialtyChips specialties={[gi, root, thyroid]} />);
expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "نمایش 2 تخصص دیگر" })).toHaveTextContent("+2");
// بقیه تا پیش از کلیک در DOM نیستند — همین کارت را کوتاه نگه می‌دارد.
expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
});
it("کلیک روی شمارنده فهرست کامل را باز می‌کند", () => {
render(<SpecialtyChips specialties={[root, gi, thyroid]} />);
fireEvent.click(screen.getByRole("button", { name: "نمایش 2 تخصص دیگر" }));
expect(screen.getByText("جراح گوارش")).toBeInTheDocument();
expect(screen.getByText("جراح تیروئید")).toBeInTheDocument();
// تخصص اصلی هم در فهرست هست تا تصویر کامل باشد.
expect(screen.getAllByText(/جراحی عمومی/).length).toBeGreaterThan(1);
});
it("کلیک روی شمارنده نباید لینک کارت را فعال کند", () => {
// کارت داخل <Link> است؛ بدون preventDefault/stopPropagation هر بار که کاربر
// تخصص‌ها را می‌بیند به صفحهٔ پزشک پرت می‌شود.
const onParentClick = vi.fn();
render(
<Link href="/doctor/uuid" onClick={onParentClick}>
<SpecialtyChips specialties={[root, gi]} />
</Link>
);
// fireEvent مقدار false می‌دهد وقتی preventDefault صدا زده شده باشد.
const notPrevented = fireEvent.click(
screen.getByRole("button", { name: "نمایش 1 تخصص دیگر" })
);
expect(notPrevented).toBe(false);
expect(onParentClick).not.toHaveBeenCalled();
});
it("تک‌تخصص هیچ شمارنده‌ای ندارد", () => {
render(<SpecialtyChips specialties={[root]} />);
expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("پزشک بدون تخصص چیزی رندر نمی‌کند و کرش نمی‌کند", () => {
const { container: empty } = render(<SpecialtyChips specialties={[]} />);
expect(empty).toBeEmptyDOMElement();
const { container: missing } = render(<SpecialtyChips specialties={undefined} />);
expect(missing).toBeEmptyDOMElement();
});
it("شمارنده متن دارد نه فقط علامت — برای صفحه‌خوان", () => {
render(<SpecialtyChips specialties={[root, gi, thyroid]} />);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("type", "button");
expect(button).toHaveAccessibleName("نمایش 2 تخصص دیگر");
});
});
+6 -16
View File
@@ -21,7 +21,7 @@ function gregorianMonthsOf(jalaliMonth) {
});
}
function DatePicker({ setDate, clinicUuid = null, resourceUuid = null, selectedServiceUuids = [] }) {
function DatePicker({ setDate, clinicUuid = null }) {
const params = useParams();
const doctorUuid = params?.doctorId;
@@ -36,20 +36,15 @@ function DatePicker({ setDate, clinicUuid = null, resourceUuid = null, selectedS
// هر محل، روزهای غیرفعال و وضعیت نوبت‌دهی آنلاین خودش را دارد؛ کش ماه‌ها باید
// با تعویض محل دور ریخته شود وگرنه تقویم محل قبلی باقی می‌ماند.
//
// منبع هم همین‌طور: تقویم هر دستگاه مالِ خودش است، و مدتِ سرویس‌های انتخاب‌شده
// تعیین می‌کند کدام روز جا دارد — پس تغییر هرکدام یعنی کشِ باطل.
const serviceKey = selectedServiceUuids.join(",");
useEffect(() => {
loadedMonths.current = new Set();
setDisabledSet(new Set());
setOnlineEnabled(true);
setAutoSelected(false);
}, [clinicUuid, resourceUuid, serviceKey]);
}, [clinicUuid]);
useEffect(() => {
if (!doctorUuid && !resourceUuid) return;
if (!doctorUuid) return;
const targets = [
...gregorianMonthsOf(baseMonth),
@@ -60,13 +55,8 @@ function DatePicker({ setDate, clinicUuid = null, resourceUuid = null, selectedS
targets.forEach(({ year, month }) => {
loadedMonths.current.add(`${year}-${month}`);
// منبع پرچمِ `online_booking_enabled` ندارد — آن روی برنامهٔ هفتگیِ پزشک است.
// نبودِ فیلد یعنی «روشن»، و شرطِ پایین‌تر فقط با `false` صریح واکنش نشان می‌دهد.
const req = resourceUuid
? request.getResourceMonthAvailability(resourceUuid, year, month, selectedServiceUuids)
: request.getMonthAvailability(doctorUuid, year, month, clinicUuid);
req
request
.getMonthAvailability(doctorUuid, year, month, clinicUuid)
.then((res) => {
const data = res?.data ?? {};
if (data.online_booking_enabled === false) setOnlineEnabled(false);
@@ -83,7 +73,7 @@ function DatePicker({ setDate, clinicUuid = null, resourceUuid = null, selectedS
loadedMonths.current.delete(`${year}-${month}`);
});
});
}, [doctorUuid, baseMonth, clinicUuid, resourceUuid, serviceKey]);
}, [doctorUuid, baseMonth, clinicUuid]);
const isDisabled = (date) => {
const day = moment(date).startOf("day");
+4 -11
View File
@@ -17,7 +17,6 @@ function DateTime({
serviceMode = false,
selectedServiceUuids = [],
clinicUuid = null,
resourceUuid = null,
selectedLocation = null,
}) {
const [value, setValue] = useState(0);
@@ -49,17 +48,11 @@ function DateTime({
setHour();
const dateStr = moment.unix(date).format("YYYY-MM-DD");
// منبع تقویم خودش را دارد، پس زمان‌هایش از اندپوینت خودش می‌آید نه از برنامهٔ
// هفتگی پزشک. شکل پاسخ همان `start_times` است، پس adapter مشترک می‌ماند.
const req = resourceUuid
const req = serviceMode
? request
.getResourceSlots(resourceUuid, dateStr, selectedServiceUuids)
.getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid)
.then(adaptServiceSlots)
: serviceMode
? request
.getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid)
.then(adaptServiceSlots)
: request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots);
: request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots);
req
.then((parsed) => {
@@ -76,7 +69,7 @@ function DateTime({
setAppo("در حال حاضر، نوبتی برای این روز موجود نمی‌باشد.");
setValue(0);
});
}, [date, doctor?.uuid, serviceMode, selectedServiceUuids, clinicUuid, resourceUuid]);
}, [date, doctor?.uuid, serviceMode, selectedServiceUuids, clinicUuid]);
return (
<div className="w-full">
-85
View File
@@ -1,85 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, waitFor } from '@testing-library/react';
vi.mock('@/services/response', () => ({
request: {
getAppointmentSlots: vi.fn(() => Promise.resolve({ data: { sessions: [] } })),
getServiceSlots: vi.fn(() => Promise.resolve({ data: { start_times: [] } })),
getResourceSlots: vi.fn(() => Promise.resolve({ data: { start_times: [] } })),
getMonthAvailability: vi.fn(() => Promise.resolve({ data: {} })),
getResourceMonthAvailability: vi.fn(() => Promise.resolve({ data: {} })),
},
}));
vi.mock('next/navigation', () => ({ useParams: () => ({ doctorId: 'doc-1' }) }));
vi.mock('@/components/common/InlineJalaliMonth', () => ({ default: () => <div /> }));
vi.mock('./dateTime/hours', () => ({ default: () => <div /> }));
vi.mock('./dateTime/SendAppo', () => ({ default: () => <div /> }));
vi.mock('../Tabs', () => ({ default: () => <div /> }));
import { request } from '@/services/response';
import DateTime from './dateTime';
import DatePicker from './datePicker';
// نیمه‌شبِ فردا — تا «زمانِ گذشته» جریان را کوتاه نکند.
const TOMORROW = Math.floor(new Date(new Date().setHours(24, 0, 0, 0)).getTime() / 1000);
describe('DateTime — انتخاب اندپوینت اسلات', () => {
beforeEach(() => vi.clearAllMocks());
it('بدون منبع و بدون حالت سرویسی → اسلات پزشک', async () => {
render(<DateTime date={TOMORROW} doctor={{ uuid: 'doc-1' }} />);
await waitFor(() => expect(request.getAppointmentSlots).toHaveBeenCalled());
expect(request.getResourceSlots).not.toHaveBeenCalled();
});
it('حالت سرویسیِ پزشک → اسلات سرویسیِ پزشک', async () => {
render(
<DateTime date={TOMORROW} doctor={{ uuid: 'doc-1' }} serviceMode selectedServiceUuids={['s1']} />
);
await waitFor(() => expect(request.getServiceSlots).toHaveBeenCalled());
expect(request.getResourceSlots).not.toHaveBeenCalled();
});
it('با منبع → اسلات منبع، حتی وقتی برنامهٔ محل اسلاتی است', async () => {
render(
<DateTime
date={TOMORROW}
doctor={{ uuid: 'doc-1' }}
resourceUuid="r1"
selectedServiceUuids={['s1']}
/>
);
await waitFor(() => expect(request.getResourceSlots).toHaveBeenCalledWith('r1', expect.any(String), ['s1']));
expect(request.getAppointmentSlots).not.toHaveBeenCalled();
expect(request.getServiceSlots).not.toHaveBeenCalled();
});
});
describe('DatePicker — انتخاب اندپوینت تقویم ماه', () => {
beforeEach(() => vi.clearAllMocks());
it('بدون منبع → تقویم پزشک', async () => {
render(<DatePicker setDate={vi.fn()} />);
await waitFor(() => expect(request.getMonthAvailability).toHaveBeenCalled());
expect(request.getResourceMonthAvailability).not.toHaveBeenCalled();
});
it('با منبع → تقویم منبع، همراه سرویس‌های انتخاب‌شده', async () => {
render(<DatePicker setDate={vi.fn()} resourceUuid="r1" selectedServiceUuids={['s1']} />);
await waitFor(() =>
expect(request.getResourceMonthAvailability).toHaveBeenCalledWith(
'r1',
expect.any(Number),
expect.any(Number),
['s1']
)
);
expect(request.getMonthAvailability).not.toHaveBeenCalled();
});
});
-25
View File
@@ -1,30 +1,5 @@
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 (
+4 -35
View File
@@ -7,7 +7,6 @@ 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";
@@ -65,28 +64,6 @@ const getBookingLocations = cache(async (doctorUuid) => {
}
});
/**
* منابع قابل رزروِ پزشک (دستگاه، اتاق، یونیت) در همهٔ محیط‌هایش.
*
* جدا از محل‌ها گرفته می‌شود چون منبع تقویم خودش را دارد: پزشکی که برنامهٔ هفتگی ندارد
* هیچ محلی برنمی‌گرداند، ولی ممکن است دستگاهش کاملاً قابل رزرو باشد.
*/
const getBookingResources = cache(async (doctorUuid) => {
if (!doctorUuid) return [];
try {
const res = await fetch(
`${API_URL}/api/v1/appointment-booking-resources/${doctorUuid}`,
{ next: { revalidate: 3600, tags: [`booking-resources-${doctorUuid}`] } }
);
if (!res.ok) return [];
const json = await res.json();
const data = json?.data?.data ?? json?.data ?? {};
return Array.isArray(data.resources) ? data.resources : [];
} catch {
return [];
}
});
export async function generateMetadata({ params }) {
const { slug } = await params;
const { matchedCity } = await getStateInfo();
@@ -166,13 +143,9 @@ async function Doctor({ params }) {
} catch (error) {}
}
const [addresses, bookingLocations, bookingResources] = doctor
? await Promise.all([
getDoctorAddresses(doctor.id),
getBookingLocations(doctor.uuid),
getBookingResources(doctor.uuid),
])
: [[], [], []];
const [addresses, bookingLocations] = doctor
? await Promise.all([getDoctorAddresses(doctor.id), getBookingLocations(doctor.uuid)])
: [[], []];
// آدرسی که در هیچ برنامهٔ نوبت‌دهی استفاده نشده، محل مراجعه نیست. در کارت
// «موقعیت مکانی» می‌ماند (اطلاعات واقعی مطب است) ولی به JSON-LD نمی‌رود، چون
@@ -273,10 +246,7 @@ async function Doctor({ params }) {
}
: null;
// همان تخصصی که سربرگ صفحه نشان می‌دهد؛ اگر اینجا اولین آیتم آرایه می‌ماند،
// BreadcrumbList و نمای صفحه دو چیز متفاوت می‌گفتند.
const specialtyLabel =
splitSpecialties(doctor?.specialties).primary?.name || "پزشکان";
const specialtyLabel = doctor?.specialties?.[0]?.name || "پزشکان";
// مقصد تمیز صفحهٔ فرود تخصص؛ اگر تخصص slug نداشت، به لیست پزشکان برمی‌گردیم.
const specialtySlug = specialtiesData.find(
(item) => item.name === specialtyLabel && item.status === 1
@@ -345,7 +315,6 @@ async function Doctor({ params }) {
addresses={addresses}
bookableAddressUuids={[...bookableAddressUuids]}
bookingLocations={bookingLocations}
bookingResources={bookingResources}
slug={slug}
faq={faq}
/>
+54
View File
@@ -74,6 +74,60 @@ body {
background: #fafafa;
}
/* ── توکن‌های جریان رزرو ──────────────────────────────────────────────────
*
* جریان رزرو رنگ‌هایش را hard-code داشت، پس در دارک‌مود دقیقاً همان سفید می‌ماند.
* این‌ها **رنگ تازه نیستند**: مقادیر روشن عیناً همان چیزی است که تا امروز بود، و
* نسخهٔ تیره از همان‌ها مشتق شده. طراحی تازه‌ای اینجا اختراع نشده.
*/
:root {
--ap-surface: #ffffff;
--ap-surface-2: #fafafa;
--ap-surface-3: #f5f5f5;
--ap-border: #efefef;
--ap-border-2: #d7d7d7;
--ap-heading: #2f2f2f;
--ap-text: #3b3b3b;
--ap-text-2: #616161;
--ap-text-3: #7a7a7a;
--ap-text-4: #9b9b9b;
--ap-muted: #525252;
--ap-primary: #5559ce;
--ap-accent: #f17732;
--ap-accent-strong: #b4541f;
--ap-danger: #e0383b;
--ap-danger-bg: #fdeeee;
--ap-danger-border: #f3c0c1;
--ap-warning: #f4b740;
--ap-warning-bg: #fff8e8;
--ap-warning-text: #8a6100;
}
[data-theme="dark"] {
--ap-surface: #1b1b20;
--ap-surface-2: #232329;
--ap-surface-3: #2a2a31;
--ap-border: #2e2e36;
--ap-border-2: #3a3a44;
--ap-heading: #f0f0f2;
--ap-text: #e8e8ea;
--ap-text-2: #b8b8be;
--ap-text-3: #98989f;
--ap-text-4: #82828a;
--ap-muted: #c9c9cf;
/* بنفش و نارنجی روی زمینهٔ تیره کم‌کنتراست‌اند؛ کمی روشن‌تر شده‌اند نه عوض. */
--ap-primary: #8a8de6;
--ap-accent: #ff9152;
--ap-accent-strong: #ffb184;
--ap-danger: #ff6b6e;
--ap-danger-bg: #3a1f20;
--ap-danger-border: #5a2e30;
--ap-warning: #ffca5c;
--ap-warning-bg: #33290f;
--ap-warning-text: #ffd98a;
}
/* transition-colors only on theme-sensitive elements, not every DOM node */
.dark,
[data-theme] {
+2 -6
View File
@@ -10,7 +10,6 @@ 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;
@@ -43,8 +42,7 @@ const getDoctors = cache(async (specialtyId, cityId) => {
});
export async function generateMetadata({ params }) {
const { slug: rawSlug } = await params;
const slug = decodeRouteParam(rawSlug);
const { slug } = await params;
const specialty = findSpecialty(slug);
if (!specialty) notFound();
@@ -75,8 +73,7 @@ export async function generateMetadata({ params }) {
}
async function SpecialtyDetail({ params }) {
const { slug: rawSlug } = await params;
const slug = decodeRouteParam(rawSlug);
const { slug } = await params;
const specialty = findSpecialty(slug);
if (!specialty) notFound();
@@ -138,7 +135,6 @@ async function SpecialtyDetail({ params }) {
/>
<Layout name="/specialties">
<SpecialtyDetailPage
specialty={specialty}
specialtyName={specialty.name}
cityName={cityName}
doctors={doctors}
+8 -4
View File
@@ -1,11 +1,15 @@
import Image from "next/image";
import React from "react";
import SiteLogo from "@/components/common/SiteLogo";
function Head({ siteName, slogan, city }) {
function Head({ siteName, slogan }) {
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]">
{/* رنگ پیش‌فرض لوگو #5559CE است و روی همین پس‌زمینه گم می‌شود؛ نسخهٔ روشن. */}
<SiteLogo city={city} stroke="#FAFAFA" className="w-[46px] h-[46px]" />
<Image
src="/assets/images/logo-2.png"
height={46}
width={46}
alt="logo"
/>
<h1 className="text-[#FAFAFA] text-[16px] sm:text-[18px] md:text-[22px] lg:text-[24px] font-bold">
درباره {siteName}
</h1>
-46
View File
@@ -1,46 +0,0 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import Head from "@/components/aboutUs/Head";
const CITY = { name: "یاسوج", site_name: "یاسوج نوبت", logo_url: null };
describe("سربرگ درباره ما", () => {
it("شهر بدون logo_url لوگوی درون‌خطی با رنگ روشن می‌گیرد", () => {
const { container } = render(
<Head siteName={CITY.site_name} slogan="کنار شما" city={CITY} />
);
const path = container.querySelector("svg path");
// پس‌زمینهٔ هدر #5559C2 است؛ رنگ پیش‌فرض لوگو روی آن دیده نمی‌شود.
expect(path).toHaveAttribute("stroke", "#FAFAFA");
});
it("شهر دارای logo_url همان تصویر را نشان می‌دهد", () => {
render(
<Head
siteName={CITY.site_name}
city={{ ...CITY, logo_url: "https://cdn.test/logo.png" }}
/>
);
expect(screen.getByRole("img", { name: "logo" })).toHaveAttribute(
"src",
"https://cdn.test/logo.png"
);
});
it("لوگوی قدیمی logo-2.png دیگر رندر نمی‌شود", () => {
const { container } = render(<Head siteName={CITY.site_name} city={CITY} />);
expect(container.innerHTML).not.toContain("logo-2");
});
it("شعار نداشتن، سربرگ را نمی‌شکند", () => {
render(<Head siteName={CITY.site_name} city={CITY} />);
expect(
screen.getByRole("heading", { name: `درباره ${CITY.site_name}` })
).toBeInTheDocument();
});
});
+2 -25
View File
@@ -1,27 +1,20 @@
import { getStateInfo } from "@/lib/getStateInfo";
import { buildAboutIntro } from "@/lib/staticPageIntro";
import Head from "./Head";
import Services from "./Services";
async function AboutUsPage() {
const { matchedCity, matchedState, isRoot } = await getStateInfo();
const { matchedCity } = 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} city={matchedCity} />
<Head siteName={siteName} slogan={slogan} />
<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} یک سامانهی آنلاین نوبتدهی پزشکی است که با هدف سادهتر کردن
دسترسی مردم به خدمات سلامت طراحی شده است. ما تلاش میکنیم تجربهی رزرو
@@ -34,22 +27,6 @@ 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>
+5 -32
View File
@@ -1,6 +1,5 @@
import Date from "./date";
import ServiceSelect from "./service";
import ResourceSelect from "./resource";
import LocationSelect from "./location/LocationSelect";
// Components
@@ -54,31 +53,15 @@ 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 (noBookingAtAll) {
if (bookingLocations.length === 0) {
dateStep = (
<p className="w-full max-w-[520px] mx-auto py-[40px] text-center text-[14px] text-[#7A7A7A]">
<p className="w-full max-w-[520px] mx-auto py-[40px] text-center text-[14px] text-[var(--ap-text-3)]">
نوبتدهی آنلاین برای این پزشک فعال نیست.
</p>
);
@@ -90,19 +73,12 @@ 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
services={bookingServices}
doctorUuid={doctor?.uuid}
clinicUuid={clinicUuid}
onContinue={(uuids) => setSelectedServiceUuids(uuids)}
/>
);
@@ -119,9 +95,7 @@ function Container({
onChangeService={() => setSelectedServiceUuids([])}
selectedLocation={selectedLocation}
clinicUuid={clinicUuid}
resourceUuid={resourceUuid}
onChangeLocation={multiLocation ? reopenLocationChoice : null}
onChangeResource={resources.length > 0 ? reopenResourceChoice : null}
onDateChange={onDateChange}
closedOnDate={selectedClosedOnDate}
/>
@@ -162,7 +136,6 @@ function Container({
setAppointmentExpiresAt={setAppointmentExpiresAt}
selectedServiceUuids={selectedServiceUuids}
clinicUuid={clinicUuid}
resourceUuid={resourceUuid}
/>,
<Paying
setStep={setStep}
-142
View File
@@ -1,142 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
// جریان نوبت به کوکی، تم، تقویم و لایه‌ی API وابسته است؛ اینجا فقط تصمیمِ
// «کدام مرحله رندر می‌شود» سنجیده می‌شود، پس بقیه mock می‌شوند.
vi.mock('@/components/layout', () => ({ default: ({ children }) => <div>{children}</div> }));
vi.mock('./Content', () => ({ default: ({ children }) => <div>{children}</div> }));
vi.mock('./date', () => ({ default: () => <div>مرحله-روز</div> }));
vi.mock('@/components/appointment/paying', () => ({ default: () => null }));
vi.mock('@/components/appointment/detail', () => ({ default: () => null }));
vi.mock('@/components/appointment/failedPay', () => ({ default: () => null }));
vi.mock('@/components/appointment/successPay', () => ({ default: () => null }));
vi.mock('@/components/register/LogInPage', () => ({ default: () => null }));
vi.mock('@/components/register/verificationPage', () => ({ default: () => null }));
vi.mock('@/components/register/LayoutRegister', () => ({ default: ({ children }) => <div>{children}</div> }));
import Container from './Container';
const RESOURCE = {
uuid: 'r1',
name: 'کندلا2021',
type: { name: 'دستگاه لیزر' },
services: [{ uuid: 's1', name: 'لیزر دست', duration_minutes: 20 }],
};
const LOCATION = { clinic_uuid: null, title: 'مطب شخصی', booking_mode: 'slot', services: [] };
const props = (over = {}) => ({
step: 0,
data: {},
doctor: { uuid: 'd1' },
setData: vi.fn(),
setStep: vi.fn(),
prevData: {},
matchedCity: {},
isForAnother: false,
setIsForAnother: vi.fn(),
disabledDates: [],
setNum: vi.fn(),
setUuid: vi.fn(),
setIsSendMsg: vi.fn(),
setSelectedSlot: vi.fn(),
setSelectedDate: vi.fn(),
setAppointmentId: vi.fn(),
setAppointmentExpiresAt: vi.fn(),
bookingMode: 'slot',
bookingServices: [],
selectedServiceUuids: [],
setSelectedServiceUuids: vi.fn(),
bookingLocations: [LOCATION],
selectedLocation: LOCATION,
locationConfirmed: true,
changeLocation: vi.fn(),
reopenLocationChoice: vi.fn(),
onDateChange: vi.fn(),
selectedClosedOnDate: false,
resources: [],
selectedResource: null,
resourceStepNeeded: false,
changeResource: vi.fn(),
reopenResourceChoice: vi.fn(),
...over,
});
describe('مرحلهٔ انتخاب نوع نوبت در جریان رزرو', () => {
beforeEach(() => vi.clearAllMocks());
it('پزشکِ بدون منبع → هیچ تغییری؛ مستقیم مرحلهٔ روز', () => {
render(<Container {...props()} />);
expect(screen.getByText('مرحله-روز')).toBeInTheDocument();
expect(screen.queryByText('انتخاب نوع نوبت')).not.toBeInTheDocument();
});
it('منبع هست و هنوز انتخاب نشده → مرحلهٔ نوع نوبت', () => {
render(<Container {...props({ resources: [RESOURCE], resourceStepNeeded: true })} />);
expect(screen.getByText('۱. انتخاب نوع نوبت')).toBeInTheDocument();
expect(screen.getByText('کندلا2021')).toBeInTheDocument();
expect(screen.queryByText('مرحله-روز')).not.toBeInTheDocument();
});
it('محلِ تأییدنشده مقدم بر مرحلهٔ منبع است', () => {
render(
<Container
{...props({
bookingLocations: [LOCATION, { ...LOCATION, clinic_uuid: 'c2', title: 'کلینیک' }],
locationConfirmed: false,
resources: [RESOURCE],
resourceStepNeeded: true,
})}
/>
);
expect(screen.getByText('۱. انتخاب محل نوبت‌دهی')).toBeInTheDocument();
expect(screen.queryByText('۱. انتخاب نوع نوبت')).not.toBeInTheDocument();
});
it('پزشکِ بدون برنامهٔ هفتگی ولی دارای منبع → مرحلهٔ منبع، نه پیام «فعال نیست»', () => {
render(
<Container
{...props({
bookingLocations: [],
selectedLocation: null,
resources: [{ ...RESOURCE, clinic_uuid: 'c5' }],
resourceStepNeeded: true,
})}
/>
);
expect(screen.getByText('۱. انتخاب نوع نوبت')).toBeInTheDocument();
expect(screen.getByText('کندلا2021')).toBeInTheDocument();
expect(
screen.queryByText('نوبت‌دهی آنلاین برای این پزشک فعال نیست.')
).not.toBeInTheDocument();
// ویزیت مستقیم وجود ندارد، پس کارتش هم نباید باشد.
expect(screen.queryByText('نوبت با پزشک')).not.toBeInTheDocument();
});
it('نه محل و نه منبع → همان پیام «فعال نیست»', () => {
render(<Container {...props({ bookingLocations: [], selectedLocation: null })} />);
expect(screen.getByText('نوبت‌دهی آنلاین برای این پزشک فعال نیست.')).toBeInTheDocument();
});
it('بعد از انتخاب منبع، مرحلهٔ سرویس با سرویس‌های همان منبع می‌آید', () => {
render(
<Container
{...props({
resources: [RESOURCE],
selectedResource: RESOURCE,
resourceStepNeeded: false,
bookingMode: 'service',
bookingServices: RESOURCE.services,
})}
/>
);
expect(screen.getByText('۱. انتخاب سرویس')).toBeInTheDocument();
expect(screen.getByText('لیزر دست')).toBeInTheDocument();
});
});
@@ -1,15 +1,7 @@
import DatePicker from "@/app/component/date/datePicker";
function SelectDatePicker({ setDate, disabledDates, clinicUuid, resourceUuid = null, selectedServiceUuids = [] }) {
return (
<DatePicker
setDate={setDate}
disabledDates={disabledDates}
clinicUuid={clinicUuid}
resourceUuid={resourceUuid}
selectedServiceUuids={selectedServiceUuids}
/>
);
function SelectDatePicker({ setDate, disabledDates, clinicUuid }) {
return <DatePicker setDate={setDate} disabledDates={disabledDates} clinicUuid={clinicUuid} />;
}
export default SelectDatePicker;
+3 -9
View File
@@ -1,18 +1,12 @@
import SelectDatePicker from "./SelectDatePicker";
function Time({ isStep, setDate, disabledDates, clinicUuid, resourceUuid = null, selectedServiceUuids = [] }) {
function Time({ isStep, setDate, disabledDates, clinicUuid }) {
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">
<p className="text-[var(--ap-text)] text-[14px] md:text-[16px] font-bold">
1. انتخاب روز
</p>
<SelectDatePicker
setDate={setDate}
disabledDates={disabledDates}
clinicUuid={clinicUuid}
resourceUuid={resourceUuid}
selectedServiceUuids={selectedServiceUuids}
/>
<SelectDatePicker setDate={setDate} disabledDates={disabledDates} clinicUuid={clinicUuid} />
{!isStep && (
<div className="absolute left-0 top-0 w-full h-full bg-[rgba(255,255,255,0.84)]"></div>
)}
+2 -3
View File
@@ -1,9 +1,9 @@
import DateTime from "@/app/component/date/dateTime";
function Hour({ doctor, setStep, date, isStep, setSelectedSlot, setSelectedDate, serviceMode = false, selectedServiceUuids = [], clinicUuid = null, resourceUuid = null, selectedLocation = null }) {
function Hour({ doctor, setStep, date, isStep, setSelectedSlot, setSelectedDate, serviceMode = false, selectedServiceUuids = [], clinicUuid = null, selectedLocation = null }) {
return (
<div className="flex relative mt-[24px] flex-col items-start gap-[12px] justify-start">
<p className="text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
<p className="text-[var(--ap-text)] text-[14px] md:text-[16px] font-bold">
2. انتخاب ساعت
</p>
<DateTime
@@ -15,7 +15,6 @@ function Hour({ doctor, setStep, date, isStep, setSelectedSlot, setSelectedDate,
serviceMode={serviceMode}
selectedServiceUuids={selectedServiceUuids}
clinicUuid={clinicUuid}
resourceUuid={resourceUuid}
selectedLocation={selectedLocation}
/>
{!isStep && (
+7 -21
View File
@@ -13,9 +13,7 @@ function Date({
onChangeService,
selectedLocation = null,
clinicUuid = null,
resourceUuid = null,
onChangeLocation,
onChangeResource,
onDateChange,
closedOnDate = false,
}) {
@@ -29,33 +27,24 @@ function Date({
return (
<div className="w-full lg:w-[61%]">
<div
className="p-0 lg:p-[24px] rounded-[8px] border border-solid border-transparent lg:border-[#EFEFEF] bg-transparent lg:bg-[#FFF]"
className="p-0 lg:p-[24px] rounded-[8px] border border-solid border-transparent lg:border-[var(--ap-border)] bg-transparent lg:bg-[var(--ap-surface)]"
>
{(onChangeLocation || onChangeResource || (serviceMode && onChangeService)) && (
{(onChangeLocation || (serviceMode && onChangeService)) && (
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1">
{onChangeLocation && (
<button
type="button"
onClick={onChangeLocation}
className="text-[13px] text-[#5559CE] hover:underline"
className="text-[13px] text-[var(--ap-primary)] hover:underline"
>
تغییر محل{selectedLocation?.title ? ` (${selectedLocation.title})` : ""}
</button>
)}
{onChangeResource && (
<button
type="button"
onClick={onChangeResource}
className="text-[13px] text-[#5559CE] hover:underline"
>
تغییر نوع نوبت
</button>
)}
{serviceMode && onChangeService && (
<button
type="button"
onClick={onChangeService}
className="text-[13px] text-[#5559CE] hover:underline"
className="text-[13px] text-[var(--ap-primary)] hover:underline"
>
تغییر سرویس
</button>
@@ -63,14 +52,14 @@ function Date({
</div>
)}
{closedOnDate && (
<div className="mb-3 rounded-[8px] border border-solid border-[#F17732] bg-[rgba(241,119,50,0.08)] p-[12px]">
<p className="text-[13px] text-[#B4541F] leading-relaxed">
<div className="mb-3 rounded-[8px] border border-solid border-[var(--ap-accent)] bg-[rgba(241,119,50,0.08)] p-[12px]">
<p className="text-[13px] text-[var(--ap-accent-strong)] leading-relaxed">
«{selectedLocation?.title}» در روز انتخابشده نوبتدهی ندارد.
{onChangeLocation && (
<button
type="button"
onClick={onChangeLocation}
className="mr-1 font-medium text-[#5559CE] hover:underline"
className="mr-1 font-medium text-[var(--ap-primary)] hover:underline"
>
انتخاب محل دیگر
</button>
@@ -83,8 +72,6 @@ function Date({
isStep
disabledDates={disabledDates}
clinicUuid={clinicUuid}
resourceUuid={resourceUuid}
selectedServiceUuids={selectedServiceUuids}
/>
<Hour
isStep
@@ -96,7 +83,6 @@ function Date({
serviceMode={serviceMode}
selectedServiceUuids={selectedServiceUuids}
clinicUuid={clinicUuid}
resourceUuid={resourceUuid}
selectedLocation={selectedLocation}
/>
</div>
+2 -2
View File
@@ -2,9 +2,9 @@ function Content({ isConfirmed, children, title }) {
return (
<div className="flex w-full flex-col items-start justify-start gap-[8px] sm:gap-[10px] md:gap-[13px] lg:gap-[16px]">
<div className="flex min-h-[32px] items-center justify-start gap-[8px] md:gap-[6px] lg:gap-[4px]">
<p className="text-[#525252] text-[14px] font-medium">{title}</p>
<p className="text-[var(--ap-muted)] text-[14px] font-medium">{title}</p>
{isConfirmed && (
<p className="py-[4px] md:py-[2px] leading-[8px] sm:leading-[14px] md:leading-[21px] lg:leading-[28px] px-[5px] bg-[#05BA58] rounded-[40px] text-[#FFF] text-[10px] md:text-[12px] font-normal">
<p className="py-[4px] md:py-[2px] leading-[8px] sm:leading-[14px] md:leading-[21px] lg:leading-[28px] px-[5px] bg-[#05BA58] rounded-[40px] text-[var(--ap-surface)] text-[10px] md:text-[12px] font-normal">
تایید شده
</p>
)}
+2 -2
View File
@@ -27,8 +27,8 @@ function Form({ changeData, insurance, supplementaryInsurance, data, isForAnothe
{isForAnother ? (
<EditField changeData={changeData} data={data?.phone} name="phone" error={errors?.phone} />
) : (
<div className="flex items-center justify-between px-[14px] py-[12.5px] border border-[#D7D7D7] rounded-lg bg-[#F5F5F5]">
<span className="text-[#3B3B3B]">{data?.phone?.value}</span>
<div className="flex items-center justify-between px-[14px] py-[12.5px] border border-[var(--ap-border-2)] rounded-lg bg-[var(--ap-surface-3)]">
<span className="text-[var(--ap-text)]">{data?.phone?.value}</span>
<span className="text-[#4CAF50] text-[14px]">تایید شده</span>
</div>
)}
+2 -5
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, resourceUuid = null }) {
function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt, selectedServiceUuids = [], clinicUuid = null }) {
const [loading, setLoading] = useState(false);
const { cityId } = useProvince();
const newStep = () => setStep((prev) => prev + 1);
@@ -150,9 +150,6 @@ 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(),
@@ -198,7 +195,7 @@ function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother,
onClick={handleSubmit}
>
<p
className={`${loading && "opacity-0"} text-[#EFEFEF] text-[16px] font-medium`}
className={`${loading && "opacity-0"} text-[var(--ap-border)] text-[16px] font-medium`}
>
ثبت اطلاعات
</p>
@@ -1,70 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('@/services/response', () => ({
request: {
postAppointment: vi.fn(() => Promise.resolve({ data: { data: { uuid: 'a1' } } })),
patchUserProfile: vi.fn(() => Promise.resolve({})),
postUserProfile: vi.fn(() => Promise.resolve({})),
},
}));
vi.mock('@/context/ProvinceProvider', () => ({ useProvince: () => ({ cityId: 7 }) }));
vi.mock('js-cookie', () => ({ default: { get: () => undefined, set: vi.fn() } }));
vi.mock('@/lib/tokenStore', () => ({ setAccessToken: vi.fn() }));
vi.mock('../paying/ButtonFixed', () => ({ default: ({ children }) => <div>{children}</div> }));
vi.mock('@/components/icons/ArrowLeftB', () => ({ default: () => null }));
import { request } from '@/services/response';
import SubmitData from './SubmitData';
const VALID = {
uuid: 'p1',
phone: { value: '09120000000' },
national_code: { value: '0012345678' },
name: { value: 'علی' },
family: { value: 'رضایی' },
gender: { value: 'male' },
basic_insurance: { value: { id: 3 } },
};
const props = (over = {}) => ({
setStep: vi.fn(),
data: VALID,
prevData: VALID,
setErrors: vi.fn(),
doctor: { uuid: 'doc-1' },
isForAnother: false,
selectedSlot: { start: 1786339800, end: 1786341000 },
selectedDate: 1786339800,
setAppointmentId: vi.fn(),
setAppointmentExpiresAt: vi.fn(),
selectedServiceUuids: ['s1'],
clinicUuid: null,
...over,
});
const submit = async (over) => {
render(<SubmitData {...props(over)} />);
await userEvent.click(screen.getByText('ثبت اطلاعات'));
return request.postAppointment.mock.calls[0]?.[0];
};
describe('payload ثبت نوبت', () => {
beforeEach(() => vi.clearAllMocks());
it('نوبت با پزشک → بدون resource_uuid', async () => {
const payload = await submit();
expect(payload).toBeDefined();
expect(payload).not.toHaveProperty('resource_uuid');
expect(payload.service_item_uuids).toEqual(['s1']);
});
it('نوبت منبع‌محور → resource_uuid فرستاده می‌شود', async () => {
const payload = await submit({ resourceUuid: 'r1' });
expect(payload.resource_uuid).toBe('r1');
expect(payload.doctor_uuid).toBe('doc-1');
});
});
+4 -6
View File
@@ -22,7 +22,6 @@ function Detail({
setAppointmentExpiresAt,
selectedServiceUuids = [],
clinicUuid = null,
resourceUuid = null,
}) {
const [insurance, setInsurance] = useState();
const [supplementaryInsurance, setSupplementaryInsurance] = useState();
@@ -79,10 +78,10 @@ function Detail({
}, []);
return (
// ${typePay === 'success' || typePay === 'failed' ? 'bg-transparent border-transparent md:bg-[#FFF] md:border-[#EFEFEF]' : ''}
// ${typePay === 'success' || typePay === 'failed' ? 'bg-transparent border-transparent md:bg-[var(--ap-surface)] md:border-[var(--ap-border)]' : ''}
<div className="w-full lg:w-[59%]">
<div
className={`opacity-page bg-[#FFF] border border-solid border-[#EFEFEF] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px] `}
className={`opacity-page bg-[var(--ap-surface)] border border-solid border-[var(--ap-border)] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px] `}
>
{/* دکمه برگشت */}
<div
@@ -92,7 +91,7 @@ function Detail({
<ArrowRightS />
</div>
<p className="text-[#3B3B3B] text-[20px] font-bold">
<p className="text-[var(--ap-text)] text-[20px] font-bold">
{isForAnother ? "اطلاعات کاربر جدید" : "اطلاعات حساب کاربری"}
</p>
<Form
@@ -108,7 +107,7 @@ function Detail({
onClick={() => (isForAnother ? switchToSelf() : switchToAnother())}
>
<AddCircleBlueA />
<p className="text-[#5559CE] text-[16px] font-medium">
<p className="text-[var(--ap-primary)] text-[16px] font-medium">
{isForAnother ? "نوبت برای خودم است" : "نوبت برای شخص دیگری است"}
</p>
</Button>
@@ -125,7 +124,6 @@ function Detail({
setAppointmentExpiresAt={setAppointmentExpiresAt}
selectedServiceUuids={selectedServiceUuids}
clinicUuid={clinicUuid}
resourceUuid={resourceUuid}
/>
</div>
</div>
+3 -3
View File
@@ -8,7 +8,7 @@ function FailedPay({ setStep, fadeElement }) {
return (
<div className="w-full lg:w-[59%]">
<div
className={`opacity-page bg-transparent lg:bg-[#FFF] border border-solid border-transparent lg:border-[#EFEFEF] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
className={`opacity-page bg-transparent lg:bg-[var(--ap-surface)] border border-solid border-transparent lg:border-[var(--ap-border)] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
>
<div className="mt-[7px]">
<div className="flex flex-col justify-center items-center gap-[14px]">
@@ -24,7 +24,7 @@ function FailedPay({ setStep, fadeElement }) {
className="!flex !rounded-[8px] !items-center !p-2 !justify-center !gap-[4px]"
>
<RedoA />
<p className="text-[#5559CE] text-[16px] font-medium">
<p className="text-[var(--ap-primary)] text-[16px] font-medium">
صفحه اصلی
</p>
</Button>
@@ -35,7 +35,7 @@ function FailedPay({ setStep, fadeElement }) {
className="!flex !rounded-[8px] !items-center !p-2 !justify-center !gap-[4px]"
>
<RefreshA />
<p className="text-[#FAFAFA] text-[16px] font-medium">
<p className="text-[var(--ap-surface-2)] text-[16px] font-medium">
تلاش دوباره
</p>
</Button>
+4 -75
View File
@@ -65,12 +65,6 @@ 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);
@@ -126,81 +120,21 @@ 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 =
selectedResource !== null || selectedLocation?.booking_mode === "service"
? "service"
: "slot";
const bookingServices = selectedResource?.services ?? selectedLocation?.services ?? [];
const bookingMode = selectedLocation?.booking_mode === "service" ? "service" : "slot";
const bookingServices = 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);
@@ -281,8 +215,8 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-[#5559CE] border-t-transparent rounded-full animate-spin"></div>
<p className="text-[#3B3B3B] text-[16px]">در حال بارگذاری...</p>
<div className="w-12 h-12 border-4 border-[var(--ap-primary)] border-t-transparent rounded-full animate-spin"></div>
<p className="text-[var(--ap-text)] text-[16px]">در حال بارگذاری...</p>
</div>
</div>
);
@@ -323,11 +257,6 @@ 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}
/>
+10 -10
View File
@@ -29,7 +29,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
<div className="w-[18px] h-[18px] md:w-[20px] md:h-[20px]">
<LocationA />
</div>
<p className="text-[#616161] text-[14px] md:text-[16px] font-normal">
<p className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-normal">
آدرس: {address}
</p>
</li>
@@ -39,10 +39,10 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
<CalendarA />
</div>
<div className="flex items-center justify-start">
<p className="text-[#616161] text-[14px] md:text-[16px] font-medium">
<p className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
تاریخ نوبت:{" "}
</p>
<p className="text-[#2F2F2F] text-[14px] md:text-[16px] font-medium mr-1">
<p className="text-[var(--ap-heading)] text-[14px] md:text-[16px] font-medium mr-1">
{formattedDate}
</p>
</div>
@@ -53,7 +53,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
onClick={() => setStep(0)}
variant="text"
>
<p className="!text-[#F17732] !text-[12px] md:!text-[14px] !font-normal">
<p className="!text-[var(--ap-accent)] !text-[12px] md:!text-[14px] !font-normal">
تغییر{" "}
</p>
<div className="w-[16px] h-[16px] sm:w-[18px] sm:h-[18px] md:w-[21px] md:h-[21px] lg:w-[24px] lg:h-[24px]">
@@ -68,10 +68,10 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
<ClockA />
</div>
<div className="flex items-center justify-start">
<p className="text-[#616161] text-[14px] md:text-[16px] font-medium">
<p className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
زمان نوبت:{" "}
</p>
<p className="text-[#2F2F2F] text-[14px] md:text-[16px] font-medium mr-1">
<p className="text-[var(--ap-heading)] text-[14px] md:text-[16px] font-medium mr-1">
ساعت {formattedTime}
</p>
</div>
@@ -82,7 +82,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
onClick={() => setStep(0)}
variant="text"
>
<p className="!text-[#F17732] !text-[12px] md:!text-[14px] !font-normal">
<p className="!text-[var(--ap-accent)] !text-[12px] md:!text-[14px] !font-normal">
تغییر{" "}
</p>
<div className="w-[16px] h-[16px] sm:w-[18px] sm:h-[18px] md:w-[21px] md:h-[21px] lg:w-[24px] lg:h-[24px]">
@@ -93,7 +93,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
</li>
</ul>
<span
className="mt-[10px] sm:mt-[16px] md:mt-[22px] lg:mt-[28px] mb-[8px] sm:mb-[13px] md:mb-[19px] lg:mb-[24px] w-full h-px bg-[#D7D7D7] block"
className="mt-[10px] sm:mt-[16px] md:mt-[22px] lg:mt-[28px] mb-[8px] sm:mb-[13px] md:mb-[19px] lg:mb-[24px] w-full h-px bg-[var(--ap-border-2)] block"
></span>
<ul className="flex flex-col items-start justify-start gap-[8px] sm:gap-[13px] md:gap-[19px] lg:gap-[24px]">
{/* <li className="flex items-center justify-start gap-[4px] md:gap-[6px] lg:gap-[8px]">
@@ -102,7 +102,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
>
<TickCircleOrange />
</div>
<p className="text-[#616161] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
<p className="text-[var(--ap-text-2)] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
فقط در صورت لغو نوبت تا 5 ساعت قبل از زمان ویزیت، امکان استرداد وجه
ممکن می باشد.
</p>
@@ -113,7 +113,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
>
<TickCircleOrange />
</div>
<p className="text-[#616161] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
<p className="text-[var(--ap-text-2)] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
لطفا به موقع در مطب حضور داشته باشید.
</p>
</li>
+2 -2
View File
@@ -16,10 +16,10 @@ function Head({ doctor }) {
className=" w-[40px] sm:w-[58px] md:w-[77px] lg:w-[95px] h-[40px] sm:h-[58px] md:h-[77px] lg:h-[95px] "
/>
<div className="flex flex-col items-start justify-center gap-[8px] sm:gap-[12px] md:gap-[16px] lg:gap-[20px]">
<p className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
<p className="text-[var(--ap-text)] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
{doctor?.name}
</p>
<p className="text-[#616161] text-[14px] md:text-[16px] font-medium">
<p className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
تخصص: {expertiseText}
</p>
</div>
+2 -2
View File
@@ -9,9 +9,9 @@ function Information({ doctor, step, setStep, typePay, isPay, disableSide, selec
className={`w-full lg:w-[41%] relative ${disableSide ? "hidden lg:flex" : "" }`}
>
<div
className={`p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px] sticky right-0 w-full opacity-page rounded-[8px] border border-solid border-[#EFEFEF] bg-[#FFF] top-[calc(12px_+_75px_+_32px)] sm:top-[calc(21px_+_75px_+_45px)] md:top-[calc(30px_+_75px_+_58px)] lg:top-[calc(40px_+_75px_+_78px)] ${typePay === "success" || typePay === "failed" ? "hidden md:block" : "block" } `}
className={`p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px] sticky right-0 w-full opacity-page rounded-[8px] border border-solid border-[var(--ap-border)] bg-[var(--ap-surface)] top-[calc(12px_+_75px_+_32px)] sm:top-[calc(21px_+_75px_+_45px)] md:top-[calc(30px_+_75px_+_58px)] lg:top-[calc(40px_+_75px_+_78px)] ${typePay === "success" || typePay === "failed" ? "hidden md:block" : "block" } `}
>
<p className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
<p className="text-[var(--ap-text)] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
جزئیات نوبت
</p>
<Head doctor={doctor} />
+3 -3
View File
@@ -7,7 +7,7 @@ function Address({ doctor, selectedLocation }) {
return (
<ul className="hidden mt-[4px] lg:flex flex-col">
<li>
<div className="flex py-[12px] text-[#616161] text-[14px] items-start justify-start">
<div className="flex py-[12px] text-[var(--ap-text-2)] text-[14px] items-start justify-start">
<p className="font-bold min-w-[115px]">{`${selectedLocation.title}: `}</p>
<p className="font-normal">{selectedLocation.address || "—"}</p>
</div>
@@ -20,12 +20,12 @@ function Address({ doctor, selectedLocation }) {
<ul className="hidden mt-[4px] lg:flex flex-col">
{doctor?.address?.map((item, idx) => (
<li key={idx}>
<div className="flex py-[12px] text-[#616161] text-[14px] items-start justify-start">
<div className="flex py-[12px] text-[var(--ap-text-2)] text-[14px] items-start justify-start">
<p className="font-bold min-w-[115px]">{`${item.name}: `}</p>
<p className="font-normal">{item.address || item.location}</p>
</div>
{doctor.address.length > idx + 1 && (
<span className="bg-[#EFEFEF] block h-px w-full"></span>
<span className="bg-[var(--ap-border)] block h-px w-full"></span>
)}
</li>
))}
+2 -2
View File
@@ -14,10 +14,10 @@ function Head({ doctor }) {
className=" w-[48px] sm:w-[51px] md:w-[54px] lg:w-[56px] h-[48px] sm:h-[51px] md:h-[54px] lg:h-[56px] "
/>
<div className="flex flex-col items-start justify-start gap-[8px]">
<h2 className="text-[#3B3B3B] text-[12px] md:text-[14px] font-bold">
<h2 className="text-[var(--ap-text)] text-[12px] md:text-[14px] font-bold">
{doctor?.name}
</h2>
<h3 className="text-[#525252] text-[12px] md:text-[14px] font-medium">
<h3 className="text-[var(--ap-muted)] text-[12px] md:text-[14px] font-medium">
تخصص: {expertiseText}
</h3>
</div>
@@ -18,10 +18,10 @@ function LocationSelect({ locations = [], selected, onSelect }) {
return (
<div className="w-full max-w-[520px] mx-auto">
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب محل نوبتدهی</h2>
<h2 className="text-[16px] font-bold text-[var(--ap-text)] mb-4">۱. انتخاب محل نوبتدهی</h2>
{!anyOpen && locations.length > 0 && (
<p className="mb-3 text-[13px] text-[#B4541F]">
<p className="mb-3 text-[13px] text-[var(--ap-accent-strong)]">
در روز انتخابشده هیچکدام از محلها نوبتدهی ندارند. روز دیگری را انتخاب کنید.
</p>
)}
@@ -43,32 +43,32 @@ function LocationSelect({ locations = [], selected, onSelect }) {
onClick={() => !closed && onSelect(location)}
className={`w-full text-right p-[16px] rounded-[8px] border border-solid transition-colors ${
closed
? "border-[#EFEFEF] bg-[#FAFAFA] opacity-60 cursor-not-allowed"
? "border-[var(--ap-border)] bg-[var(--ap-surface-2)] opacity-60 cursor-not-allowed"
: active
? "border-[#5559CE] bg-[rgba(85,89,206,0.06)]"
: "border-[#EFEFEF] bg-[#FFF] hover:border-[#C7C9EC]"
? "border-[var(--ap-primary)] bg-[rgba(85,89,206,0.06)]"
: "border-[var(--ap-border)] bg-[var(--ap-surface)] hover:border-[#C7C9EC]"
}`}
>
<div className="flex items-center justify-between gap-[8px]">
<p className="text-[14px] md:text-[16px] font-bold text-[#3B3B3B]">
<p className="text-[14px] md:text-[16px] font-bold text-[var(--ap-text)]">
{location.title}
</p>
{location.type === "clinic" && (
<span className="text-[11px] text-[#5559CE] bg-[rgba(85,89,206,0.10)] rounded-[4px] px-[6px] py-[2px]">
<span className="text-[11px] text-[var(--ap-primary)] bg-[rgba(85,89,206,0.10)] rounded-[4px] px-[6px] py-[2px]">
کلینیک
</span>
)}
</div>
{location.address && (
<p className="mt-[6px] text-[13px] text-[#616161] font-normal">
<p className="mt-[6px] text-[13px] text-[var(--ap-text-2)] font-normal">
{location.address}
</p>
)}
<p
className={`mt-[8px] text-[12px] ${
closed ? "text-[#B4541F]" : nextLabel ? "text-[#009D79]" : "text-[#A1A1A1]"
closed ? "text-[var(--ap-accent-strong)]" : nextLabel ? "text-[#009D79]" : "text-[#A1A1A1]"
}`}
>
{closed
+2 -2
View File
@@ -5,10 +5,10 @@ function Location({ doctor, selectedLocation }) {
return (
<div className="h-fit lg:h-screen w-full lg:w-[39%]">
<div
className="p-0 lg:p-[16px] sticky rounded-[8px] bg-transparent lg:bg-[#FFF] border border-solid border-transparent lg:border-[#EFEFEF] top-[calc(12px_+_75px_+_32px)] sm:top-[calc(21px_+_75px_+_45px)] md:top-[calc(30px_+_75px_+_58px)] lg:top-[calc(40px_+_75px_+_78px)]"
className="p-0 lg:p-[16px] sticky rounded-[8px] bg-transparent lg:bg-[var(--ap-surface)] border border-solid border-transparent lg:border-[var(--ap-border)] top-[calc(12px_+_75px_+_32px)] sm:top-[calc(21px_+_75px_+_45px)] md:top-[calc(30px_+_75px_+_58px)] lg:top-[calc(40px_+_75px_+_78px)]"
>
<Head doctor={doctor} />
<span className="block bg-[#EFEFEF] lg:bg-transparent w-full h-px lg:my-[6px] mt-[16px]"></span>
<span className="block bg-[var(--ap-border)] lg:bg-transparent w-full h-px lg:my-[6px] mt-[16px]"></span>
<Address doctor={doctor} selectedLocation={selectedLocation} />
</div>
</div>
+1 -1
View File
@@ -3,7 +3,7 @@ import React from "react";
function ButtonFixed({ children }) {
return (
<div
className="!fixed md:!relative !bottom-0 !right-0 bg-[#FFF] border-0 border-t md:border-t-transparent border-[#D7D7D7] z-10 !w-full sm:!w-full !p-[16px] sm:!px-[52px] md:!ml-0 md:!p-0 md:!w-full"
className="!fixed md:!relative !bottom-0 !right-0 bg-[var(--ap-surface)] border-0 border-t md:border-t-transparent border-[var(--ap-border-2)] z-10 !w-full sm:!w-full !p-[16px] sm:!px-[52px] md:!ml-0 md:!p-0 md:!w-full"
>
{children}
</div>
+26 -26
View File
@@ -69,29 +69,29 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
return (
<div className="w-full lg:w-[59%]">
<div className="opacity-page bg-[#FFF] border border-solid border-[#EFEFEF] rounded-[12px] p-[16px] md:p-[24px]">
<p className="text-[#3B3B3B] text-[16px] md:text-[20px] font-bold mb-[20px]">
<div className="opacity-page bg-[var(--ap-surface)] border border-solid border-[var(--ap-border)] rounded-[12px] p-[16px] md:p-[24px]">
<p className="text-[var(--ap-text)] text-[16px] md:text-[20px] font-bold mb-[20px]">
تأیید و پرداخت
</p>
{/* ── خلاصه نوبت ── */}
<div className="rounded-[10px] border border-[#EFEFEF] bg-[#FAFAFA] p-[16px] flex flex-col gap-[12px]">
<div className="rounded-[10px] border border-[var(--ap-border)] bg-[var(--ap-surface-2)] p-[16px] flex flex-col gap-[12px]">
{doctor?.name && (
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
<span className="text-[#9B9B9B]">پزشک</span>
<span className="text-[#3B3B3B] font-medium">{doctor.name}</span>
<span className="text-[var(--ap-text-4)]">پزشک</span>
<span className="text-[var(--ap-text)] font-medium">{doctor.name}</span>
</div>
)}
{patientName && (
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
<span className="text-[#9B9B9B]">بیمار</span>
<span className="text-[#3B3B3B] font-medium">{patientName}</span>
<span className="text-[var(--ap-text-4)]">بیمار</span>
<span className="text-[var(--ap-text)] font-medium">{patientName}</span>
</div>
)}
{selectedSlot?.start && (
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
<span className="text-[#9B9B9B]">زمان نوبت</span>
<span className="text-[#3B3B3B] font-medium" dir="rtl">
<span className="text-[var(--ap-text-4)]">زمان نوبت</span>
<span className="text-[var(--ap-text)] font-medium" dir="rtl">
{/* utcOffset(210) = ساعت رسمی ایران (+03:30)، مستقل از tz مرورگر بیمار */}
{moment.unix(selectedSlot.start).utcOffset(210).locale("fa").format("dddd jD jMMMM jYYYY")}
{" — ساعت "}
@@ -102,12 +102,12 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
</div>
{/* ── مبلغ ── */}
<div className="flex justify-between items-center mt-[20px] py-[14px] border-y border-[#EFEFEF]">
<span className="text-[#616161] text-[14px] md:text-[16px] font-medium">
<div className="flex justify-between items-center mt-[20px] py-[14px] border-y border-[var(--ap-border)]">
<span className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
مبلغ قابل پرداخت
</span>
<span className="text-[#3B3B3B] text-[18px] md:text-[20px] font-bold">
{toman} <span className="text-[14px] font-normal text-[#616161]">تومان</span>
<span className="text-[var(--ap-text)] text-[18px] md:text-[20px] font-bold">
{toman} <span className="text-[14px] font-normal text-[var(--ap-text-2)]">تومان</span>
</span>
</div>
@@ -115,18 +115,18 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
{!expired && (
<div className="mt-[20px]">
<div className="flex justify-between items-center mb-[8px]">
<span className="text-[#616161] text-[13px] md:text-[14px]">
<span className="text-[var(--ap-text-2)] text-[13px] md:text-[14px]">
مهلت پرداخت و نگهداری نوبت
</span>
<span
className={`text-[14px] md:text-[16px] font-bold tabular-nums ${ timeLeft < 60 ? "text-[#E0383B]" : "text-[#5559CE]" }`}
className={`text-[14px] md:text-[16px] font-bold tabular-nums ${ timeLeft < 60 ? "text-[var(--ap-danger)]" : "text-[var(--ap-primary)]" }`}
>
{formatTime(timeLeft)}
</span>
</div>
<div className="w-full h-[6px] rounded-full bg-[#EFEFEF] overflow-hidden">
<div className="w-full h-[6px] rounded-full bg-[var(--ap-border)] overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-1000 ease-linear ${ timeLeft < 60 ? "bg-[#E0383B]" : "bg-[#5559CE]" }`}
className={`h-full rounded-full transition-all duration-1000 ease-linear ${ timeLeft < 60 ? "bg-[var(--ap-danger)]" : "bg-[var(--ap-primary)]" }`}
style={{ width: `${progress}%` }}
/>
</div>
@@ -137,18 +137,18 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
{!expired && (
<div className="mt-[20px]">
{testMode ? (
<div className="flex items-start gap-[8px] rounded-[10px] border border-[#F4B740] bg-[#FFF8E8] p-[12px]">
<div className="flex items-start gap-[8px] rounded-[10px] border border-[var(--ap-warning)] bg-[var(--ap-surface)8E8] p-[12px]">
<span className="text-[18px] leading-none">🧪</span>
<div>
<p className="text-[#8A6100] text-[14px] font-bold">درگاه پرداخت آزمایشی</p>
<p className="text-[#8A6100] text-[12px] mt-[2px] leading-[20px]">
<p className="text-[var(--ap-warning-text)] text-[14px] font-bold">درگاه پرداخت آزمایشی</p>
<p className="text-[var(--ap-warning-text)] text-[12px] mt-[2px] leading-[20px]">
در این حالت پرداخت واقعی انجام نمیشود و نوبت بهصورت آزمایشی تأیید میگردد.
</p>
</div>
</div>
) : gateways.length === 0 ? (
<div className="rounded-[10px] border border-[#F3C0C1] bg-[#FDEEEE] p-[14px]">
<p className="text-[#E0383B] text-[14px] font-medium">
<div className="rounded-[10px] border border-[var(--ap-danger-border)] bg-[var(--ap-danger-bg)] p-[14px]">
<p className="text-[var(--ap-danger)] text-[14px] font-medium">
درگاه پرداخت فعالی موجود نیست. لطفاً بعداً تلاش کنید.
</p>
</div>
@@ -173,8 +173,8 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
)}
{expired && (
<div className="mt-[20px] rounded-[10px] border border-[#F3C0C1] bg-[#FDEEEE] p-[14px]">
<p className="text-[#E0383B] text-[14px] font-medium">
<div className="mt-[20px] rounded-[10px] border border-[var(--ap-danger-border)] bg-[var(--ap-danger-bg)] p-[14px]">
<p className="text-[var(--ap-danger)] text-[14px] font-medium">
مهلت پرداخت تمام شد و نوبت آزاد شد. لطفاً دوباره زمان نوبت را انتخاب کنید.
</p>
</div>
@@ -188,7 +188,7 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
onClick={() => setStep(0)}
variant="contained"
>
<span className="text-[#FFF] text-[16px] font-medium">انتخاب مجدد زمان</span>
<span className="text-[var(--ap-surface)] text-[16px] font-medium">انتخاب مجدد زمان</span>
</Button>
) : (
<Button
@@ -200,7 +200,7 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
(!testMode && !selectedBank)
}
>
<span className={`text-[#FFF] text-[16px] font-medium ${loading ? "opacity-0" : ""}`}>
<span className={`text-[var(--ap-surface)] text-[16px] font-medium ${loading ? "opacity-0" : ""}`}>
{testMode ? "پرداخت آزمایشی" : `پرداخت ${toman} تومان`}
</span>
{!loading && <ArrowLeftB />}
@@ -1,64 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ResourceSelect from './index';
const resource = (over = {}) => ({
uuid: 'r1',
name: 'کندلا2021',
type: { code: 'laser_device', name: 'دستگاه لیزر' },
services: [{ uuid: 's1', name: 'لیزر دست', duration_minutes: 20, price_rials: 2000000 }],
...over,
});
describe('ResourceSelect', () => {
it('کارت «نوبت با پزشک» همیشه اول می‌آید', () => {
render(<ResourceSelect resources={[resource()]} onSelect={vi.fn()} />);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(2);
expect(buttons[0]).toHaveTextContent('نوبت با پزشک');
expect(buttons[1]).toHaveTextContent('کندلا2021');
});
it('نوع منبع به‌عنوان برچسب دیده می‌شود', () => {
render(<ResourceSelect resources={[resource()]} onSelect={vi.fn()} />);
expect(screen.getByText('دستگاه لیزر')).toBeInTheDocument();
});
it('انتخاب «نوبت با پزشک» مقدار null می‌فرستد', async () => {
const onSelect = vi.fn();
render(<ResourceSelect resources={[resource()]} onSelect={onSelect} />);
await userEvent.click(screen.getByText('نوبت با پزشک'));
expect(onSelect).toHaveBeenCalledWith(null);
});
it('انتخاب منبع، خودِ آبجکت منبع را می‌فرستد', async () => {
const onSelect = vi.fn();
const r = resource();
render(<ResourceSelect resources={[r]} onSelect={onSelect} />);
await userEvent.click(screen.getByText('کندلا2021'));
expect(onSelect).toHaveBeenCalledWith(r);
});
it('تا سه سرویس با نام می‌آید و بقیه با +n', () => {
const services = ['الف', 'ب', 'ج', 'د', 'ه'].map((n, i) => ({ uuid: `s${i}`, name: n }));
render(<ResourceSelect resources={[resource({ services })]} onSelect={vi.fn()} />);
expect(screen.getByText('الف، ب، ج +2')).toBeInTheDocument();
});
it('منبعِ بدون سرویس، زیرنویس خالی نمی‌سازد', () => {
render(<ResourceSelect resources={[resource({ services: [] })]} onSelect={vi.fn()} />);
// فقط زیرنویسِ کارتِ پزشک می‌ماند.
expect(screen.getAllByRole('button')[1].textContent).toBe('کندلا2021دستگاه لیزر');
});
it('منبعِ بدون type برچسب نمی‌گیرد و نمی‌شکند', () => {
render(<ResourceSelect resources={[resource({ type: null })]} onSelect={vi.fn()} />);
expect(screen.getByText('کندلا2021')).toBeInTheDocument();
});
});
-84
View File
@@ -1,84 +0,0 @@
"use client";
const MAX_PREVIEW_SERVICES = 3;
/**
* زیرنویس کارت: نام سرویس‌ها تا سه‌تا و بعد «+n».
*
* شمارِ خالی («۳ سرویس») به بیمار نمی‌گوید این دستگاه به کارش می‌آید یا نه، و او را
* مجبور می‌کند کورکورانه کلیک کند.
*/
function servicesLabel(services = []) {
if (services.length === 0) return null;
const shown = services.slice(0, MAX_PREVIEW_SERVICES).map((s) => s.name);
const rest = services.length - shown.length;
return rest > 0 ? `${shown.join("، ")} +${rest}` : shown.join("، ");
}
/**
* انتخاب نوع نوبت — پیش از انتخاب سرویس و روز، و فقط وقتی این پزشک در این محل
* دست‌کم یک منبعِ قابل رزرو دارد.
*
* «منبع» واژهٔ داخلی است و به بیمار نشان داده نمی‌شود؛ کارت‌ها نام واقعی دستگاه/اتاق
* و نوعشان را نشان می‌دهند. کارت اول همیشه جریان همیشگی است: نوبت با خودِ پزشک.
*/
function ResourceSelect({ resources = [], selected = null, onSelect, allowDoctorOption = true }) {
const cardClass = (active) =>
`w-full text-right p-[16px] rounded-[8px] border border-solid transition-colors ${
active
? "border-[#5559CE] bg-[rgba(85,89,206,0.06)]"
: "border-[#EFEFEF] bg-[#FFF] hover:border-[#C7C9EC]"
}`;
return (
<div className="w-full max-w-[520px] mx-auto">
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب نوع نوبت</h2>
<div className="flex flex-col gap-2">
{/* پزشکی که برنامهٔ هفتگی ندارد، ویزیتِ مستقیم هم ندارد؛ نشان‌دادن این کارت
کاربر را به فهرست ساعتِ همیشه‌خالی می‌برد. */}
{allowDoctorOption && (
<button type="button" onClick={() => onSelect(null)} className={cardClass(selected === null)}>
<p className="text-[14px] md:text-[16px] font-bold text-[#3B3B3B]">نوبت با پزشک</p>
<p className="mt-[6px] text-[13px] text-[#616161] font-normal">
ویزیت معمول در ساعتهای کاری پزشک
</p>
</button>
)}
{resources.map((resource) => {
const active = selected?.uuid === resource.uuid;
const label = servicesLabel(resource.services);
return (
<button
key={resource.uuid}
type="button"
onClick={() => onSelect(resource)}
className={cardClass(active)}
>
<div className="flex items-center justify-between gap-[8px]">
<p className="text-[14px] md:text-[16px] font-bold text-[#3B3B3B]">
{resource.name}
</p>
{resource.type?.name && (
<span className="text-[11px] text-[#5559CE] bg-[rgba(85,89,206,0.10)] rounded-[4px] px-[6px] py-[2px] shrink-0">
{resource.type.name}
</span>
)}
</div>
{label && (
<p className="mt-[6px] text-[13px] text-[#616161] font-normal">{label}</p>
)}
</button>
);
})}
</div>
</div>
);
}
export default ResourceSelect;
+65 -16
View File
@@ -1,31 +1,77 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import moment from "jalali-moment";
import { request } from "@/services/response";
function toToman(rials) {
if (!rials) return null;
return Math.round(rials / 10).toLocaleString("fa-IR");
}
/**
* جمعِ سمتِ کلاینت — فقط fallback برای بک‌اندی که `total_duration_minutes` نمی‌دهد.
* مدت، دادهٔ سرور است: فرمولش قرار است به «زمان تنها / زمان اضافه» تغییر کند و هر
* محاسبهٔ موازی در فرانت از آن روز عددِ غلط نشان می‌دهد.
*/
function fallbackSum(services, uuids) {
return services
.filter((s) => uuids.includes(s.uuid))
.reduce((sum, s) => sum + (Number(s.duration_minutes) || 0), 0);
}
// مرحلهٔ انتخاب سرویس (فقط حالت نوبت‌دهی سرویسی) — پیش از انتخاب روز.
function ServiceSelect({ services = [], onContinue }) {
function ServiceSelect({ services = [], onContinue, doctorUuid, clinicUuid = null }) {
const [draft, setDraft] = useState([]);
/** `{ key, minutes }` — کلید همان انتخابی است که عدد به آن تعلق دارد. */
const [serverDuration, setServerDuration] = useState(null);
const toggle = (uuid) =>
setDraft((prev) =>
prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]
);
const totalMinutes = services
.filter((s) => draft.includes(s.uuid))
.reduce((sum, s) => sum + (Number(s.duration_minutes) || 0), 0);
const draftKey = draft.join(",");
// مدت را سرور حساب می‌کند. این مرحله پیش از انتخاب روز است، ولی
// `total_duration_minutes` به تاریخ وابسته نیست — بک‌اند آن را پیش از لمسِ
// شیفت‌های آن روز برمی‌گرداند، پس حتی اگر امروز تعطیل باشد و `start_times`
// خالی بیاید، عدد درست است.
useEffect(() => {
if (!doctorUuid || draftKey === "") return;
let cancelled = false;
const today = moment().format("YYYY-MM-DD");
request
.getServiceSlots(doctorUuid, today, draftKey.split(","), clinicUuid)
.then((res) => {
if (cancelled) return;
const minutes = (res?.data?.data ?? res?.data)?.total_duration_minutes;
if (typeof minutes !== "number") {
console.warn("[booking] total_duration_minutes missing — falling back to client sum");
return;
}
setServerDuration({ key: draftKey, minutes });
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [doctorUuid, clinicUuid, draftKey]);
// کهنه‌بودن **مشتق** می‌شود، نه با reset: عددِ متعلق به انتخاب قبلی خودکار نادیده
// گرفته می‌شود و effect لازم نیست در بدنه‌اش setState صدا بزند.
const serverMinutes = serverDuration?.key === draftKey ? serverDuration.minutes : null;
const totalMinutes = serverMinutes ?? fallbackSum(services, draft);
return (
<div className="w-full max-w-[520px] mx-auto">
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب سرویس</h2>
<h2 className="text-[16px] font-bold text-[var(--ap-text)] mb-4">۱. انتخاب سرویس</h2>
{services.length === 0 ? (
<p className="text-[14px] text-[#7A7A7A]">
<p className="text-[14px] text-[var(--ap-text-3)]">
در حال حاضر سرویسی برای نوبتدهی آنلاین تعریف نشده است.
</p>
) : (
@@ -40,27 +86,27 @@ function ServiceSelect({ services = [], onContinue }) {
onClick={() => toggle(s.uuid)}
className={`flex items-center justify-between gap-3 p-3 rounded-xl border text-right transition-colors ${
active
? "border-[#5559CE] bg-[#5559CE]/5"
: "border-gray-200 bg-white hover:border-[#5559CE]"
? "border-[var(--ap-primary)] bg-[var(--ap-primary)]/5"
: "border-gray-200 bg-white hover:border-[var(--ap-primary)]"
}`}
>
<span className="flex items-center gap-2 min-w-0">
<span
className={`w-4 h-4 rounded border shrink-0 grid place-items-center ${
active ? "border-[#5559CE] bg-[#5559CE]" : "border-gray-300"
active ? "border-[var(--ap-primary)] bg-[var(--ap-primary)]" : "border-gray-300"
}`}
>
{active && (
<span className="w-2 h-2 bg-white rounded-[2px]" />
)}
</span>
<span className="text-[14px] text-[#3B3B3B] truncate">
<span className="text-[14px] text-[var(--ap-text)] truncate">
{s.name}
</span>
</span>
<span className="flex items-center gap-2 shrink-0 text-[12px] text-[#7A7A7A]">
<span className="flex items-center gap-2 shrink-0 text-[12px] text-[var(--ap-text-3)]">
{s.duration_minutes ? <span>{s.duration_minutes} دقیقه</span> : null}
{toman ? <span className="text-[#5559CE]">{toman} تومان</span> : null}
{toman ? <span className="text-[var(--ap-primary)]">{toman} تومان</span> : null}
</span>
</button>
);
@@ -69,8 +115,11 @@ function ServiceSelect({ services = [], onContinue }) {
)}
{draft.length > 0 && (
<div className="mt-4 text-[13px] text-[#7A7A7A]">
مدت کل: <b className="text-[#3B3B3B]">{totalMinutes} دقیقه</b>
<div className="mt-4 text-[13px] text-[var(--ap-text-3)]">
{/* تا وقتی عدد سرور نرسیده، «تقریبی» است — عددِ جمعِ کلاینت ممکن است با
مدت واقعی نوبت یکی نباشد. */}
{serverMinutes === null ? "مدت تقریبی" : "مدت کل"}:{" "}
<b className="text-[var(--ap-text)]">{totalMinutes} دقیقه</b>
</div>
)}
@@ -78,7 +127,7 @@ function ServiceSelect({ services = [], onContinue }) {
type="button"
disabled={draft.length === 0}
onClick={() => onContinue(draft)}
className="mt-5 w-full h-[48px] rounded-xl bg-[#5559CE] text-white text-[15px] font-medium disabled:opacity-50"
className="mt-5 w-full h-[48px] rounded-xl bg-[var(--ap-primary)] text-white text-[15px] font-medium disabled:opacity-50"
>
انتخاب زمان
</button>
+4 -4
View File
@@ -7,17 +7,17 @@ function SuccessPay({ setStep }) {
return (
<div className="w-full lg:w-[59%]">
<div
className={`opacity-page bg-transparent lg:bg-[#FFF] border border-solid border-transparent lg:border-[#EFEFEF] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
className={`opacity-page bg-transparent lg:bg-[var(--ap-surface)] border border-solid border-transparent lg:border-[var(--ap-border)] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
>
<div className="pt-[8px] pb-[4px] grid place-items-center">
<div className="flex flex-col justify-center items-center gap-[14px]">
<TickCircleGreenA />
<p className="text-[#3B3B3B] text-[20px] font-bold">
<p className="text-[var(--ap-text)] text-[20px] font-bold">
پرداخت شما با موفقیت انجام شد.
</p>
</div>
<p
className="text-[#3B3B3B] text-[16px] font-normal text-center mt-[24px] sm:mt-[26px] md:mt-[29px] lg:mt-[32px] mb-[24px] md:mb-[16px]"
className="text-[var(--ap-text)] text-[16px] font-normal text-center mt-[24px] sm:mt-[26px] md:mt-[29px] lg:mt-[32px] mb-[24px] md:mb-[16px]"
>
نوبت شما تأیید شد و پیامک تأیید برای بیمار ارسال میشود. برای نمایش نوبتها به قسمت نوبتهای من بروید.{" "}
</p>
@@ -26,7 +26,7 @@ function SuccessPay({ setStep }) {
variant="text"
className="!flex !items-center !justify-center !gap-[4px]"
>
<p className="text-[16px] font-medium text-[#5559CE]">
<p className="text-[16px] font-medium text-[var(--ap-primary)]">
برو به نوبت های من
</p>
<ArrowLeftBlueA />
-55
View File
@@ -1,55 +0,0 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import Head from "@/components/blog/head";
const BLOG = {
title: "آلرژی دارویی",
author: "دکتر رضایی",
created: 1750000000,
reading_time: 5,
tag: [{ name: "آلرژی" }, { name: "قلب و عروق" }],
};
describe("سربرگ مقاله", () => {
it("برچسب «مخصوص شهر» را نشان نمی‌دهد", () => {
render(<Head data={BLOG} />);
expect(screen.queryByText("مخصوص شهر:")).toBeNull();
});
it("نویسنده و تاریخ انتشار سر جایشان می‌مانند", () => {
render(<Head data={BLOG} />);
expect(screen.getByText("نویسنده:")).toBeInTheDocument();
expect(screen.getByText("تاریخ انتشار:")).toBeInTheDocument();
});
it("هر تگ به لیست مقاله‌های همان تگ لینک می‌شود", () => {
render(<Head data={BLOG} />);
const links = screen.getAllByRole("link", { name: "قلب و عروق" });
const url = new URL(links[0].getAttribute("href"), "https://yasuj-nobat.ir");
expect(links).toHaveLength(1);
expect(url.pathname).toBe("/blogs");
// قرارداد واقعی همان چیزی است که `searchParams.get("tag")` می‌خواند،
// نه شکل دقیق encode — شکل شیئیِ href فاصله را `+` می‌کند و بردکرامب `%20`.
expect(url.searchParams.get("tag")).toBe("قلب و عروق");
});
it("تگ حاوی & با پارامتر دوم اشتباه گرفته نمی‌شود", () => {
render(<Head data={{ ...BLOG, tag: [{ name: "قلب & عروق" }] }} />);
const links = screen.getAllByRole("link", { name: "قلب & عروق" });
const url = new URL(links[0].getAttribute("href"), "https://yasuj-nobat.ir");
expect(url.searchParams.get("tag")).toBe("قلب & عروق");
expect(Array.from(url.searchParams.keys())).toEqual(["tag"]);
});
it("مقالهٔ بدون تگ هیچ فهرست تگی رندر نمی‌کند", () => {
const { container } = render(<Head data={{ ...BLOG, tag: [] }} />);
expect(container.querySelector("ul")).toBeNull();
});
});
+19 -10
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 }) {
function Head({ data, cityName }) {
const firstTag = data?.tag?.[0];
const createdDate = data?.created ? convertTimestampToJalali(data.created) : "";
// normalizeBlog تگ‌ها را به [{name}] تبدیل می‌کند؛ نامِ خالی حذف می‌شود.
@@ -44,6 +44,18 @@ function Head({ data }) {
</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">
@@ -76,18 +88,15 @@ function Head({ data }) {
)}
</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}`}>
<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
key={`${tag}-${idx}`}
className="bg-[#F5F5F5] text-[#525252] rounded-full px-[12px] py-[4px] text-[11px] md:text-[13px] font-medium"
>
{tag}
</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 }) {
function BlogPage({ blog, blogs, cityName }) {
return (
<div className="padding-responsive pt-[84px] sm:pt-[110px] md:pt-[140px] lg:pt-[168px]">
<Head data={blog} />
<Head data={blog} cityName={cityName} />
<div className="flex flex-col gap-y-[32px] lg:flex-row items-start justify-center gap-[24px] mt-[16px] sm:mt-[19px] md:mt-[21px] lg:mt-[24px]">
<Detail data={blog} />
<RelatedContent data={blogs} />
+1 -3
View File
@@ -10,10 +10,8 @@ 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}`} className="block shrink-0">
<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}
@@ -1,26 +0,0 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import Item from "@/components/blog/relatedContent/Item";
const DATA = {
uuid: "8f1c0f2e-0000-4000-8000-000000000001",
title: "تیتری که بسیار بلند است و باید ستون متن را پر کند بدون آنکه عکس را باریک کند",
created: 1750000000,
};
describe("آیتم مطالب مرتبط", () => {
it("ظرف عکس در فلکس فشرده نمی‌شود", () => {
render(<Item data={DATA} idx={0} loading={false} />);
const image = screen.getByRole("img", { name: DATA.title });
// jsdom چیدمان را اندازه نمی‌گیرد؛ آنچه قابل تست است همین قرارداد کلاس است.
expect(image.closest("a")).toHaveClass("shrink-0");
});
it("ستون متن اجازهٔ کوچک شدن دارد تا سرریز نکند", () => {
const { container } = render(<Item data={DATA} idx={0} loading={false} />);
expect(container.querySelector("li > div")).toHaveClass("min-w-0");
});
});
+1 -6
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, intro }) {
function ClinicsPage({ clinics, matchedCity, matchedState, origin }) {
const searchParams = useSearchParams();
const [page, setPage] = useState(clinics?.meta?.currentPage || 1);
const [filteredClinics, setFilteredClinics] = useState(clinics?.data);
@@ -49,11 +49,6 @@ function ClinicsPage({ clinics, matchedCity, matchedState, origin, intro }) {
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}
+1 -6
View File
@@ -1,6 +1,6 @@
import Links from "./Links";
function Call({ matchedCity, isDefault, cityIntro }) {
function Call({ matchedCity, isDefault }) {
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,11 +9,6 @@ function Call({ matchedCity, isDefault, cityIntro }) {
<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>
);
+2 -13
View File
@@ -1,24 +1,13 @@
import { getStateInfo } from "@/lib/getStateInfo";
import { buildContactIntro } from "@/lib/staticPageIntro";
import Call from "./call/Call";
import Connect from "./Connect";
async function ContactUsPage() {
const { matchedCity, matchedState, isRoot } = await getStateInfo();
// مثل «درباره ما»: این صفحه هم self-canonical شد و بدون متن شهری، تلفن و ایمیل
// یکسانِ ۳۵ دامنه دوباره duplicate می‌ساخت.
const cityIntro = isRoot
? ""
: buildContactIntro(
matchedCity?.name,
matchedState?.name,
matchedCity?.site_name,
matchedCity?.slogan
);
const { matchedCity, isRoot } = await getStateInfo();
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} cityIntro={cityIntro} />
<Call matchedCity={matchedCity} isDefault={isRoot} />
{isRoot && <Connect />}
</div>
);
@@ -55,6 +55,25 @@ function Card({ data, loading, setIsTurnsDetails }) {
</p>
</TextLoading>
</li>
{/* فیلدهای حالت نوبت‌دهی سرویسی. شرط اجباری است: نوبت اسلاتی این‌ها را ندارد و
بدون شرط، کارتِ همهٔ نوبت‌های اسلاتی می‌شکند — یعنی کل پنل کاربر. */}
{data.service_items?.length > 0 && (
<li className="flex items-start justify-between gap-[8px]">
<p className="text-[#7E7E7E] text-[14px] font-normal shrink-0">سرویس:</p>
<p className="text-[#525252] text-[14px] font-medium text-left">
{data.service_items.map((s) => s.name).join("، ")}
</p>
</li>
)}
{!data.is_reserve && data.service_total_minutes ? (
<li className="flex items-center justify-between">
<p className="text-[#7E7E7E] text-[14px] font-normal">مدت نوبت:</p>
<p className="text-[#525252] text-[14px] font-medium">
{data.service_total_minutes} دقیقه
</p>
</li>
) : null}
</ul>
<div className="mt-[12px] flex items-center justify-end px-[12px]">
<TextLoading loading={loading} width={80} height={25}>
@@ -0,0 +1,85 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import Card from "./Card";
// آیکن و لودینگ‌ها به این تست ربطی ندارند؛ فقط باید کودکشان را رندر کنند.
vi.mock("@/app/component/loading/Text", () => ({
default: ({ children }) => <>{children}</>,
}));
vi.mock("@/app/component/loading/Circular", () => ({
default: ({ children }) => <>{children}</>,
}));
vi.mock("@/components/icons/ArrowLeftCardD", () => ({ default: () => null }));
vi.mock("next/image", () => ({ default: () => null }));
const base = {
uuid: "ap1",
status: "confirmed",
slot_start: 1785567600,
doctor: { name: "دکتر تست" },
};
function renderCard(data) {
return render(<Card data={{ ...base, ...data }} loading={false} setIsTurnsDetails={() => {}} />);
}
describe("Card نوبت — فیلدهای حالت سرویسی", () => {
// ── ✅ موفق ──────────────────────────────────────────────────────────────
it("نام همهٔ سرویس‌ها و مدت نوبت را نشان می‌دهد", () => {
renderCard({
service_items: [{ uuid: "s1", name: "لیزر صورت" }, { uuid: "s2", name: "لیزر بیکینی" }],
service_total_minutes: 35,
});
expect(screen.getByText("سرویس:")).toBeInTheDocument();
expect(screen.getByText("لیزر صورت، لیزر بیکینی")).toBeInTheDocument();
expect(screen.getByText("مدت نوبت:")).toBeInTheDocument();
expect(screen.getByText("35 دقیقه")).toBeInTheDocument();
});
// ── ❌ خط سرخ: نوبت اسلاتی ────────────────────────────────────────────────
it("نوبت اسلاتی هیچ‌کدام از دو ردیف را نمی‌گیرد و کرش نمی‌کند", () => {
renderCard({ service_items: [], service_total_minutes: null });
expect(screen.queryByText("سرویس:")).not.toBeInTheDocument();
expect(screen.queryByText("مدت نوبت:")).not.toBeInTheDocument();
// ردیف‌های موجود سر جایشان‌اند.
expect(screen.getByText("تاریخ نوبت:")).toBeInTheDocument();
expect(screen.getByText("ساعت نوبت:")).toBeInTheDocument();
});
it("فیلدهای سرویسی کاملاً غایب (بک‌اند قدیمی) هم کرش نمی‌دهد", () => {
renderCard({});
expect(screen.queryByText("سرویس:")).not.toBeInTheDocument();
expect(screen.getByText("تاریخ نوبت:")).toBeInTheDocument();
});
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
it("نوبت رزرو سرویس را نشان می‌دهد ولی مدت را نه (زمان ندارد)", () => {
renderCard({
is_reserve: true,
service_items: [{ uuid: "s1", name: "لیزر" }],
service_total_minutes: 45,
});
expect(screen.getByText("لیزر")).toBeInTheDocument();
expect(screen.queryByText("مدت نوبت:")).not.toBeInTheDocument();
});
it("سرویس بدون مدت → فقط ردیف سرویس", () => {
renderCard({ service_items: [{ uuid: "s1", name: "مشاوره" }], service_total_minutes: null });
expect(screen.getByText("مشاوره")).toBeInTheDocument();
expect(screen.queryByText("مدت نوبت:")).not.toBeInTheDocument();
});
it("مدت صفر ردیف نمی‌سازد", () => {
renderCard({ service_items: [{ uuid: "s1", name: "مشاوره" }], service_total_minutes: 0 });
expect(screen.queryByText("مدت نوبت:")).not.toBeInTheDocument();
});
});
@@ -14,6 +14,9 @@ function Turns({ user, setIsTurnsDetails, loading }) {
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [selectedAppointment, setSelectedAppointment] = useState(null);
// تغییر عمدیِ کاربر (لغو یا جابه‌جایی) باید فهرست را تازه کند؛ وگرنه نوبتی که همین
// حالا لغو شد هنوز «تأییدشده» نشان داده می‌شود.
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
const fetchAppointments = async () => {
@@ -51,7 +54,7 @@ function Turns({ user, setIsTurnsDetails, loading }) {
};
fetchAppointments();
}, [page, status]);
}, [page, status, reloadKey]);
const handlePageChange = (event, value) => {
setPage(value);
@@ -68,6 +71,10 @@ function Turns({ user, setIsTurnsDetails, loading }) {
<IsTurnsDetails
appointmentData={selectedAppointment}
setIsTurnsDetails={setSelectedAppointment}
onChanged={() => {
setSelectedAppointment(null);
setReloadKey((k) => k + 1);
}}
/>
) : (
<>
@@ -1,33 +1,39 @@
import { Button } from "@mui/material";
import ModalDeleteComment from "./modal";
import EditTurnsD from "@/components/icons/EditTurnsD";
import ArrowLeftD from "@/components/icons/ArrowLeftD";
import RescheduleModal from "./modal/RescheduleModal";
import DownloadD from "@/components/icons/DownloadD";
function ButtonData({ loading, onDownload }) {
/** نوبتی که گذشته یا از قبل لغو شده، نه لغو می‌شود نه جابه‌جا. */
function isOpen(appointment) {
if (!appointment?.uuid) return false;
if (String(appointment.status ?? "").startsWith("cancelled")) return false;
return (appointment.slot_start ?? 0) > Math.floor(Date.now() / 1000);
}
function ButtonData({ loading, onDownload, appointment, onChanged }) {
const actionable = isOpen(appointment);
return (
<div className="flex mt-[48px] md:mt-0 items-center justify-center md:justify-end gap-[16px] sm:gap-[16px] md:gap-[20px] lg:gap-[24px]">
{/* <Button
className="!rounded-[4px] !border !border-solid !border-[#D7D7D7] !shadow-none !gap-[4px] !py-[6px] md:!py-[7px] lg:!py-[8px] !px-[12px]"
disabled={loading}
variant="outlined"
onClick={onDownload}
>
<DownloadD />
دانلود اطلاعات نوبت
</Button>
<ModalDeleteComment loading={loading} />
<Button
className="!rounded-[4px] !shadow-none !gap-[4px] !py-[6px] md:!py-[7px] lg:!py-[8px] !px-[12px]"
disabled={loading}
variant="contained"
>
<EditTurnsD />
ویرایش نوبت
<div className="mr-[4px]">
<ArrowLeftD />
</div>
</Button> */}
{onDownload && (
<Button
className="!rounded-[4px] !border !border-solid !border-[#D7D7D7] !shadow-none !gap-[4px] !py-[6px] md:!py-[7px] lg:!py-[8px] !px-[12px]"
disabled={loading}
variant="outlined"
onClick={onDownload}
>
<DownloadD />
دانلود اطلاعات نوبت
</Button>
)}
{actionable && (
<>
<ModalDeleteComment loading={loading} appointment={appointment} onDone={onChanged} />
<RescheduleModal loading={loading} appointment={appointment} onDone={onChanged} />
</>
)}
</div>
);
}
@@ -90,6 +90,29 @@ function DetailLg({ appointmentData, loading }) {
</div>
</TextLoading>
</li>
{/* حالت نوبت‌دهی سرویسی. شرط اجباری: نوبت اسلاتی این فیلدها را ندارد. */}
{appointmentData?.service_items?.length > 0 && (
<li className="flex flex-wrap items-center justify-start gap-y-[12px] gap-[32px]">
<TextLoading loading={loading} width={200} height={20}>
<p className="text-[#616161] text-[14px] lg:text-[16px] font-normal">
سرویس:{" "}
<span className="text-[#2F2F2F] text-[14px] lg:text-[16px] font-medium">
{appointmentData.service_items.map((s) => s.name).join("، ")}
</span>
</p>
</TextLoading>
{!appointmentData.is_reserve && appointmentData.service_total_minutes ? (
<TextLoading loading={loading} width={110} height={20}>
<p className="text-[#616161] text-[14px] lg:text-[16px] font-normal">
مدت نوبت:{" "}
<span className="text-[#2F2F2F] text-[14px] lg:text-[16px] font-medium">
{appointmentData.service_total_minutes} دقیقه
</span>
</p>
</TextLoading>
) : null}
</li>
)}
<li className="flex flex-wrap items-center justify-start gap-y-[12px] gap-[32px]">
<TextLoading loading={loading} width={250} height={20}>
<div className="flex items-start justify-start gap-[4px]">
@@ -7,7 +7,7 @@ import { convertTimestampToJalali, convertTimestampToTime } from "@/helper";
import html2canvas from "html2canvas";
import jsPDF from "jspdf";
function DetailSm({ appointmentData, loading }) {
function DetailSm({ appointmentData, loading, onChanged }) {
const detailsRef = useRef(null);
const handleDownloadPDF = async () => {
@@ -85,6 +85,31 @@ function DetailSm({ appointmentData, loading }) {
</p>
</TextLoading>
</li>
{/* حالت نوبت‌دهی سرویسی. شرط اجباری: نوبت اسلاتی این فیلدها را ندارد. */}
{appointmentData?.service_items?.length > 0 && (
<li className="flex flex-wrap gap-x-[12px] items-start w-full justify-between mt-[12px]">
<TextLoading loading={loading} width={80} height={16}>
<p className="text-[#7E7E7E] text-[16px] font-normal shrink-0">سرویس:</p>
</TextLoading>
<TextLoading loading={loading} width={140} height={16}>
<p className="text-[#525252] text-[16px] font-medium text-left">
{appointmentData.service_items.map((s) => s.name).join("، ")}
</p>
</TextLoading>
</li>
)}
{!appointmentData?.is_reserve && appointmentData?.service_total_minutes ? (
<li className="flex flex-wrap items-center w-full justify-between mt-[12px]">
<TextLoading loading={loading} width={80} height={16}>
<p className="text-[#7E7E7E] text-[16px] font-normal">مدت نوبت:</p>
</TextLoading>
<TextLoading loading={loading} width={60} height={16}>
<p className="text-[#525252] text-[16px] font-medium">
{appointmentData.service_total_minutes} دقیقه
</p>
</TextLoading>
</li>
) : null}
<span className="block w-full h-px bg-[#EFEFEF] mt-[12px] mb-[16px]"></span>
<li className="flex flex-wrap gap-x-[12px] gap-y-[12px] items-center w-full justify-between">
<TextLoading loading={loading} width={200} height={16}>
@@ -116,7 +141,12 @@ function DetailSm({ appointmentData, loading }) {
</p>
</TextLoading>
</ul>
<ButtonData loading={loading} onDownload={handleDownloadPDF} />
<ButtonData
loading={loading}
onDownload={handleDownloadPDF}
appointment={appointmentData}
onChanged={onChanged}
/>
</div>
);
}
@@ -3,7 +3,7 @@ import { Button } from "@mui/material";
import ButtonData from "./ButtonData";
import TextLoading from "@/app/component/loading/Text";
function Head({ setIsTurnsDetails }) {
function Head({ setIsTurnsDetails, appointment, onChanged }) {
return (
<div className="hidden md:flex flex-wrap gap-x-[24px] gap-y-[12px] items-center justify-between">
<div className="flex items-center justify-start gap-[12px] sm:gap-[16px] md:gap-[20px] lg:gap-[24px]">
@@ -19,7 +19,7 @@ function Head({ setIsTurnsDetails }) {
</p>
</TextLoading>
</div>
<ButtonData loading={false} />
<ButtonData loading={false} appointment={appointment} onChanged={onChanged} />
</div>
);
}
@@ -2,12 +2,16 @@ import DetailLg from "./DetailLg";
import DetailSm from "./DetailSm";
import Head from "./Head";
function IsTurnsDetails({ appointmentData, setIsTurnsDetails }) {
function IsTurnsDetails({ appointmentData, setIsTurnsDetails, onChanged }) {
return (
<div className="md:py-[20px] md:px-[24px] opacity-page">
<Head setIsTurnsDetails={setIsTurnsDetails} />
<DetailLg appointmentData={appointmentData} loading={false} />
<DetailSm appointmentData={appointmentData} loading={false} />
<Head
setIsTurnsDetails={setIsTurnsDetails}
appointment={appointmentData}
onChanged={onChanged}
/>
<DetailLg appointmentData={appointmentData} loading={false} onChanged={onChanged} />
<DetailSm appointmentData={appointmentData} loading={false} onChanged={onChanged} />
</div>
);
}
@@ -0,0 +1,209 @@
"use client";
import { useEffect, useState } from "react";
import { Box, Button, Modal } from "@mui/material";
import moment from "moment-jalaali";
import { styleDefault } from "@/mui";
import CloseModalD from "@/components/icons/CloseModalD";
import { request } from "@/services/response";
import { adaptServiceSlots } from "@/lib/appointmentSlots";
const DAYS_AHEAD = 14;
/** چهارده روز آینده — نوبتی که بیمار خودش جابه‌جا می‌کند، ماه‌ها جلوتر نمی‌رود. */
function nextDays() {
return Array.from({ length: DAYS_AHEAD }, (_, i) => {
const day = moment().add(i, "day");
return {
value: day.format("YYYY-MM-DD"),
label: day.format("jD jMMMM"),
weekday: day.format("dddd"),
};
});
}
/**
* جابه‌جایی نوبت از پنل کاربر.
*
* مدت را **سرور** حساب می‌کند: بیمار فقط زمان شروع می‌فرستد. اگر فرانت مدت را می‌فرستاد،
* دو محاسبهٔ موازی داشتیم و روزی که تعرفه یا مدت سرویس عوض می‌شد، نوبت با مدت کهنه
* جابه‌جا می‌شد.
*
* وقت‌ها با `exclude_appointment_uuid` گرفته می‌شوند تا خودِ نوبت فعلی جای خالی حساب
* شود؛ وگرنه بیمار ساعت خودش را «پر» می‌بیند.
*/
function RescheduleModal({ appointment, loading, onDone }) {
const [open, setOpen] = useState(false);
const [date, setDate] = useState(null);
const [slots, setSlots] = useState([]);
const [picked, setPicked] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const days = nextDays();
const doctorUuid = appointment?.doctor?.uuid;
useEffect(() => {
if (!open || !date || !doctorUuid || !appointment?.uuid) return;
let cancelled = false;
setError(null);
setPicked(null);
setBusy(true);
request
.getServiceSlotsForReschedule(
doctorUuid,
date,
appointment.uuid,
(appointment.service_items ?? []).map((s) => s.uuid).filter(Boolean)
)
.then((res) => {
if (cancelled) return;
const groups = adaptServiceSlots(res);
setSlots(groups.flatMap((g) => g.slots ?? []));
})
.catch((e) => {
if (!cancelled) setError(e?.message || "گرفتن وقت‌های آزاد ناموفق بود");
})
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, [open, date, doctorUuid, appointment?.uuid]);
const close = () => {
setOpen(false);
setDate(null);
setSlots([]);
setPicked(null);
setError(null);
};
const submit = async () => {
if (!picked) return;
setBusy(true);
setError(null);
try {
await request.serviceReschedule(appointment.uuid, { start: picked.start });
close();
onDone?.();
} catch (e) {
// ۴۰۹ یعنی همین لحظه کس دیگری همان وقت را گرفت — پیام سرور دقیقاً همین را می‌گوید
// و فهرست دوباره خوانده می‌شود تا بیمار جایگزین ببیند.
setError(e?.message || "جابه‌جایی نوبت ناموفق بود");
setDate((d) => d);
setBusy(false);
}
};
return (
<div>
<Button
className="!rounded-[4px] !shadow-none !gap-[4px] !py-[6px] md:!py-[7px] lg:!py-[8px] !px-[12px]"
disabled={loading}
onClick={() => setOpen(true)}
variant="contained"
>
جابهجایی نوبت
</Button>
<Modal open={open} onClose={close}>
<Box
sx={styleDefault}
className="!w-fit !min-w-[328px] md:!w-fit md:!min-w-[552px] !py-[20px] !px-[24px]"
>
<div className="flex items-center justify-between w-full">
<p className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-medium">
جابهجایی نوبت
</p>
<Button onClick={close} className="!p-1" variant="text">
<CloseModalD />
</Button>
</div>
<span className="block h-px w-full bg-[#EFEFEF] my-[12px] md:my-[14px] lg:my-[16px]" />
<p className="text-[#525252] text-[12px] md:text-[14px] font-normal">
روز و ساعت تازه را انتخاب کنید. سرویسهای نوبت و مدت آن تغییری نمیکنند.
</p>
<div className="mt-[14px] flex gap-[8px] overflow-x-auto pb-2">
{days.map((day) => (
<button
key={day.value}
type="button"
onClick={() => setDate(day.value)}
className={`shrink-0 rounded-[6px] border px-[12px] py-[6px] text-[12px] ${
date === day.value
? "border-[#5559CE] bg-[#5559CE]/10 text-[#5559CE]"
: "border-[#EFEFEF] text-[#616161]"
}`}
>
<span className="block">{day.label}</span>
<span className="block text-[10px] text-[#9A9A9A]">{day.weekday}</span>
</button>
))}
</div>
{busy && (
<p className="mt-[12px] text-[12px] text-[#7E7E7E]">در حال بررسی وقتهای آزاد</p>
)}
{!busy && date && slots.length === 0 && !error && (
<p className="mt-[12px] text-[12px] text-[#7E7E7E]">
در این روز وقت آزادی نیست؛ روز دیگری را امتحان کنید.
</p>
)}
{slots.length > 0 && (
<div className="mt-[12px] flex flex-wrap gap-[8px]">
{slots.map((slot) => (
<button
key={slot.start}
type="button"
onClick={() => setPicked(slot)}
className={`rounded-[6px] border px-[12px] py-[6px] text-[13px] ${
picked?.start_time === slot.start_time
? "border-[#5559CE] bg-[#5559CE]/10 text-[#5559CE]"
: "border-[#EFEFEF] text-[#616161]"
}`}
>
{slot.start_time}
</button>
))}
</div>
)}
{error && <p className="mt-[12px] text-[12px] text-[#D64545]">{error}</p>}
<div className="flex items-center justify-end gap-[16px] mt-[16px]">
<Button
variant="outlined"
onClick={close}
className="!py-[4px] md:!py-[6px] lg:!py-[8px] !px-[8px] md:!px-[12px] lg:!px-[16px] !rounded-[4px] !border-[#828DE0] !text-[#828DE0] !text-[16px] !font-medium"
>
انصراف
</Button>
<Button
variant="contained"
disabled={!picked || busy}
onClick={submit}
className="!py-[4px] md:!py-[6px] lg:!py-[8px] !px-[8px] md:!px-[12px] lg:!px-[16px] !rounded-[4px] !text-[#EFEFEF] !text-[16px] !font-medium"
>
جابهجا کن
</Button>
</div>
</Box>
</Modal>
</div>
);
}
export default RescheduleModal;
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('@/services/response', () => ({
request: {
getServiceSlotsForReschedule: vi.fn(),
serviceReschedule: vi.fn(),
},
}));
import { request } from '@/services/response';
import RescheduleModal from './RescheduleModal';
const appointment = {
uuid: 'a-1',
doctor: { uuid: 'd-1' },
service_items: [{ uuid: 's-1' }, { uuid: 's-2' }],
};
const slots = {
data: {
total_duration_minutes: 30,
start_times: [
{ start: 1_900_000_000, end: 1_900_001_800, start_time: '09:00', end_time: '09:30' },
{ start: 1_900_003_600, end: 1_900_005_400, start_time: '10:00', end_time: '10:30' },
],
},
};
async function openAndPickFirstDay(user) {
render(<RescheduleModal appointment={appointment} />);
await user.click(screen.getByRole('button', { name: 'جابه‌جایی نوبت' }));
const days = screen.getAllByRole('button').filter((b) => /\d|[۰-۹]/.test(b.textContent));
await user.click(days[0]);
}
describe('مودال جابه‌جایی نوبت سایت', () => {
beforeEach(() => {
request.getServiceSlotsForReschedule.mockResolvedValue(slots);
request.serviceReschedule.mockResolvedValue({ data: {} });
});
/**
* ⭐ بدون `exclude_appointment_uuid`، بیمار ساعت خودش را «پر» می‌بیند و نمی‌تواند
* حتی به همان روز جابه‌جا شود.
*/
it('وقت‌ها را با کنارگذاشتن نوبت فعلی می‌گیرد', async () => {
const user = userEvent.setup();
await openAndPickFirstDay(user);
await waitFor(() => expect(request.getServiceSlotsForReschedule).toHaveBeenCalled());
const [doctorUuid, , appointmentUuid, serviceUuids] =
request.getServiceSlotsForReschedule.mock.calls[0];
expect(doctorUuid).toBe('d-1');
expect(appointmentUuid).toBe('a-1');
expect(serviceUuids).toEqual(['s-1', 's-2']);
});
/** مدت را سرور حساب می‌کند؛ فرانت فقط زمان شروع می‌فرستد. */
it('فقط زمان شروع را می‌فرستد، نه مدت', async () => {
const onDone = vi.fn();
const user = userEvent.setup();
render(<RescheduleModal appointment={appointment} onDone={onDone} />);
await user.click(screen.getByRole('button', { name: 'جابه‌جایی نوبت' }));
const days = screen.getAllByRole('button').filter((b) => /\d|[۰-۹]/.test(b.textContent));
await user.click(days[0]);
const slot = await screen.findByRole('button', { name: '09:00' });
await user.click(slot);
await user.click(screen.getByRole('button', { name: 'جابه‌جا کن' }));
await waitFor(() =>
expect(request.serviceReschedule).toHaveBeenCalledWith('a-1', { start: 1_900_000_000 }),
);
await waitFor(() => expect(onDone).toHaveBeenCalled());
});
it('روزِ بدون وقت آزاد را صریح می‌گوید', async () => {
request.getServiceSlotsForReschedule.mockResolvedValue({ data: { start_times: [] } });
const user = userEvent.setup();
await openAndPickFirstDay(user);
expect(await screen.findByText(/در این روز وقت آزادی نیست/)).toBeInTheDocument();
});
/** ۴۰۹ یعنی همین لحظه کس دیگری گرفت — پیام سرور باید دیده شود. */
it('پیام خطای سرور را نشان می‌دهد', async () => {
request.serviceReschedule.mockRejectedValue(new Error('این بازه زمانی قبلاً رزرو شده است'));
const user = userEvent.setup();
await openAndPickFirstDay(user);
await user.click(await screen.findByRole('button', { name: '09:00' }));
await user.click(screen.getByRole('button', { name: 'جابه‌جا کن' }));
expect(await screen.findByText('این بازه زمانی قبلاً رزرو شده است')).toBeInTheDocument();
});
});
@@ -1,12 +1,66 @@
import { useState } from "react";
"use client";
import { useEffect, useState } from "react";
import { Box, Button, Modal } from "@mui/material";
import { styleDefault } from "@/mui";
import CloseModalD from "@/components/icons/CloseModalD";
import { request } from "@/services/response";
import { numberToArStyle } from "@/helper";
function ModalDeleteComment({ loading }) {
/**
* لغو نوبت با نمایش پیامد مالی **پیش از** تأیید.
*
* پیش‌نمایش از همان محاسبه‌ای می‌آید که خودِ لغو انجام می‌دهد، پس عددی که بیمار می‌بیند
* همان است که کسر می‌شود. تا پیش از این، این مودال هیچ درخواستی نمی‌فرستاد و دکمهٔ
* «لغو نوبت» فقط پنجره را می‌بست — یعنی متن تبلیغاتی سایت وعده‌ای می‌داد که UI نداشت.
*/
function ModalDeleteComment({ loading, appointment, onDone }) {
const [open, setOpen] = useState(false);
const [preview, setPreview] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
const handleClose = () => {
setOpen(false);
setPreview(null);
setError(null);
};
useEffect(() => {
if (!open || !appointment?.uuid) return;
let cancelled = false;
request
.getCancellationPreview(appointment.uuid)
.then((res) => {
if (!cancelled) setPreview(res?.data ?? res ?? null);
})
// نبودِ پیش‌نمایش نباید لغو را قفل کند؛ فقط باید صریح گفته شود.
.catch(() => {
if (!cancelled) setPreview(null);
});
return () => {
cancelled = true;
};
}, [open, appointment?.uuid]);
const submit = async () => {
setBusy(true);
setError(null);
try {
await request.cancelAppointment(appointment.uuid, { by: "user" });
handleClose();
onDone?.();
} catch (e) {
setError(e?.message || "لغو نوبت ناموفق بود");
} finally {
setBusy(false);
}
};
return (
<div>
@@ -37,6 +91,38 @@ function ModalDeleteComment({ loading }) {
<p className="text-[#525252] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
آیا از لغو کردن نوبت مطمئن هستید؟
</p>
<div className="mt-[12px] rounded-[6px] bg-[#F7F7F7] p-[12px] text-[12px] md:text-[13px] leading-[2]">
{preview === null ? (
<span className="text-[#7E7E7E]">
پیامد مالی لغو در دسترس نیست؛ لغو انجام میشود ولی مبلغ را از پشتیبانی
بپرسید.
</span>
) : (
<>
<div className="flex items-center justify-between">
<span className="text-[#7E7E7E]">جریمهٔ لغو</span>
<strong className={preview.penalty_rials > 0 ? "text-[#D64545]" : "text-[#2E9E63]"}>
{preview.penalty_rials > 0
? `${numberToArStyle(Math.round(preview.penalty_rials / 10))} تومان`
: "بدون جریمه"}
</strong>
</div>
{preview.within_free_window && (
<div className="text-[#2E9E63]">این لغو در بازهٔ رایگان است.</div>
)}
{(preview.notes ?? []).map((note, i) => (
<div key={i} className="text-[#9A9A9A]">
{note}
</div>
))}
</>
)}
</div>
{error && <p className="mt-[10px] text-[12px] text-[#D64545]">{error}</p>}
<div className="flex items-center justify-end gap-[16px] mt-[12px]">
<Button
variant="outlined"
@@ -47,7 +133,8 @@ function ModalDeleteComment({ loading }) {
</Button>
<Button
variant="contained"
onClick={handleClose}
disabled={busy}
onClick={submit}
className="!py-[4px] md:!py-[6px] lg:!py-[8px] !px-[8px] md:!px-[12px] lg:!px-[16px] !rounded-[4px] !text-[#EFEFEF] !text-[16px] !font-medium"
>
لغو نوبت
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('@/services/response', () => ({
request: {
getCancellationPreview: vi.fn(),
cancelAppointment: vi.fn(),
},
}));
import { request } from '@/services/response';
import ModalDeleteComment from './index';
const appointment = { uuid: 'a-1', slot_start: 2_000_000_000, status: 'confirmed' };
const preview = (over = {}) => ({
data: {
penalty_rials: 2_500_000,
deposit_refundable: true,
credit_refundable: true,
within_free_window: false,
notes: [],
paid_rials: 5_000_000,
...over,
},
});
describe('مودال لغو نوبت سایت', () => {
beforeEach(() => {
request.cancelAppointment.mockResolvedValue({ data: {} });
});
/** ⭐ تا پیش از این، دکمهٔ تأیید فقط پنجره را می‌بست و هیچ درخواستی نمی‌رفت. */
it('پیش از تأیید، جریمه را از سرور نشان می‌دهد', async () => {
request.getCancellationPreview.mockResolvedValue(preview());
const user = userEvent.setup();
render(<ModalDeleteComment appointment={appointment} />);
await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
// ۲٬۵۰۰٬۰۰۰ ریال ⇒ ۲۵۰٬۰۰۰ تومان. قالب رقم به ICU محیط وابسته است، پس فقط
// «مبلغ درست + واحد» سنجیده می‌شود نه شکل رقم‌ها.
const amount = await screen.findByText(/تومان$/);
expect(amount.textContent.replace(/[^0-9۰-۹٠-٩]/g, '')).toMatch(/2500{2}0|۲۵۰۰۰۰|٢٥٠٠٠٠/);
expect(request.cancelAppointment).not.toHaveBeenCalled();
});
it('در بازهٔ رایگان «بدون جریمه» می‌گوید، نه صفر', async () => {
request.getCancellationPreview.mockResolvedValue(
preview({ penalty_rials: 0, within_free_window: true }),
);
const user = userEvent.setup();
render(<ModalDeleteComment appointment={appointment} />);
await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
expect(await screen.findByText('بدون جریمه')).toBeInTheDocument();
expect(screen.getByText('این لغو در بازهٔ رایگان است.')).toBeInTheDocument();
});
it('تأیید، لغو را واقعاً می‌فرستد و والد را خبر می‌کند', async () => {
request.getCancellationPreview.mockResolvedValue(preview());
const onDone = vi.fn();
const user = userEvent.setup();
render(<ModalDeleteComment appointment={appointment} onDone={onDone} />);
await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
await screen.findByText(/جریمهٔ لغو/);
await user.click(screen.getByRole('button', { name: 'لغو نوبت' }));
await waitFor(() =>
expect(request.cancelAppointment).toHaveBeenCalledWith('a-1', { by: 'user' }),
);
await waitFor(() => expect(onDone).toHaveBeenCalled());
});
/** پیش‌نمایشی که نیامده نباید لغو را قفل کند — ولی باید صریح بگوید نیامده. */
it('نبودِ پیش‌نمایش، لغو را قفل نمی‌کند', async () => {
request.getCancellationPreview.mockRejectedValue(new Error('down'));
const user = userEvent.setup();
render(<ModalDeleteComment appointment={appointment} />);
await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
expect(await screen.findByText(/پیامد مالی لغو در دسترس نیست/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'لغو نوبت' })).not.toBeDisabled();
});
});
@@ -5,7 +5,6 @@ import GreenCalendarTurn from "@/components/icons/GreenCalendarTurn";
import { Button } from "@mui/material";
import 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 }) {
@@ -27,9 +26,13 @@ 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}>
{/* ستون نوبت‌دهی باریک است؛ همان الگوی کارت پزشک — تخصص اصلی و
شمارندهٔ بقیه — تا شش نام پشت‌سرهم ارتفاعش را چند برابر نکند. */}
<SpecialtyChips specialties={turn?.specialties} />
<p className="text-[#525252] text-[14px] font-medium">
تخصص:
{turn?.specialties?.map(
(item, idx) =>
`${item.name} ${turn.specialties.length === idx + 1 ? "" : "|"} `
)}
</p>
</TextLoading>
</div>
</div>
@@ -22,20 +22,10 @@ const formatJalali = (ts) => {
* قابل‌رزرو را برمی‌گرداند. تا وقتی حداقل یک محل هست، نوبت‌دهی فعال است — حتی
* اگر فیلد قدیمی active به هر دلیل false باشد. بدون محل، همان فیلدهای doctor
* ملاک می‌مانند (سازگاری با پزشک بدون برنامه).
*
* منبع مسیر سومی است: دستگاه و اتاق تقویم خودشان را دارند و به برنامهٔ هفتگیِ پزشک
* وابسته نیستند. پزشکی که خودش نوبت آنلاین نمی‌دهد ولی دستگاهش می‌دهد، «نوبت‌دهی
* غیرفعال» نیست — فقط ویزیت مستقیم ندارد.
*/
export function bookingState(doctor, bookingLocations, bookingResources = []) {
export function bookingState(doctor, bookingLocations) {
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;
@@ -1,43 +0,0 @@
import { describe, it, expect } from 'vitest';
import { bookingState } from './bookingState';
const LOCATION = { next_available_at: null };
const RESOURCE = { uuid: 'r1', name: 'کندلا2021' };
describe('bookingState', () => {
it('محل دارد → فعال', () => {
expect(bookingState({ active: true }, [LOCATION]).enabled).toBe(true);
});
it('نه محل و نه منبع و نه free_turn → غیرفعال', () => {
const state = bookingState({ active: true, free_turn: null }, [], []);
expect(state.enabled).toBe(false);
expect(state.label).toBe('نوبت‌دهی غیرفعال است');
});
it('بدون محل ولی با منبع → فعال', () => {
const state = bookingState({ active: true, free_turn: null }, [], [RESOURCE]);
expect(state.enabled).toBe(true);
expect(state.label).toBe('رزرو نوبت دستگاه و خدمات');
});
it('پزشکِ غیرفعال ولی دارای منبعِ قابل رزرو → همچنان فعال', () => {
// منبع تقویم خودش را دارد؛ پرچم قدیمیِ پزشک آن را خاموش نمی‌کند.
expect(bookingState({ active: false, free_turn: null }, [], [RESOURCE]).enabled).toBe(true);
});
it('محل مقدم بر منبع است — برچسبِ زودترین نوبت حفظ می‌شود', () => {
const state = bookingState({ active: true }, [{ next_available_at: null }], [RESOURCE]);
expect(state.label).toBe('فعلاً نوبت خالی ندارد');
});
it('بدون محل و بدون منبع ولی با free_turn → همان رفتار قبلی', () => {
const state = bookingState({ active: true, free_turn: 'فردا ۱۰:۰۰' }, [], []);
expect(state.enabled).toBe(true);
expect(state.label).toBe('اولین نوبت آزاد: فردا ۱۰:۰۰');
});
});
+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 = [], bookingResources = [] }) {
const booking = bookingState(doctor, bookingLocations, bookingResources);
function AppointmentList({ doctor, loading, doctorSlug, bookingLocations = [] }) {
const booking = bookingState(doctor, bookingLocations);
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">
+9 -22
View File
@@ -15,9 +15,6 @@ 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"],
@@ -102,27 +99,17 @@ function List({ hiddenRef, data, qrReady }) {
pxHeight = canvas.height;
}
// کاغذ همیشه A4 است، نه اندازه‌ای که از نسبت تصویر درمی‌آید: خروجی قبلی
// ۲۱۰×۲۶۲٫۵ میلی‌متر بود و روی هیچ پرینتری A4 حساب نمی‌شد.
const pdf = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
const imgWidth = 210;
const pxPerMm = pxWidth / imgWidth;
const imgHeight = pxHeight / pxPerMm;
// پوستر خودش نسبت A4 دارد، پس عملاً کل صفحه را پر می‌کند. این محاسبه تور
// ایمنی است تا اگر ابعاد پوستر روزی عوض شد، تصویر از کاغذ بیرون نزند.
let width = A4_WIDTH_MM;
let height = (pxHeight / pxWidth) * width;
if (height > A4_HEIGHT_MM) {
height = A4_HEIGHT_MM;
width = (pxWidth / pxHeight) * height;
}
const pdf = new jsPDF({
orientation: "portrait",
unit: "mm",
format: [imgWidth, imgHeight],
});
pdf.addImage(
imgData,
"PNG",
(A4_WIDTH_MM - width) / 2,
(A4_HEIGHT_MM - height) / 2,
width,
height,
);
pdf.addImage(imgData, "PNG", 0, 0, imgWidth, imgHeight);
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-[1527px] top-0 left-0 opacity-0 pointer-events-none -z-10">
<div className="fixed w-[1080px] h-[1350px] top-0 left-0 opacity-0 pointer-events-none -z-10">
<div ref={hiddenRef}>
{variant === "dark" ? (
<Poster data={data} onQrReady={handleQrReady} />
+6 -14
View File
@@ -46,20 +46,12 @@ function Title({ doctor }) {
</div>
</TextLoading>
<TextLoading width={100} height={20}>
{/* اینجا جای فهرست کاملِ تخصص‌هاست — سربرگ صفحه فقط تخصص اصلی را نشان
می‌دهد. چیپ‌های شکننده جای رشتهٔ درهم نشستند: پیش از این خروجی
«تخصص:جراحی عمومی | … | » بود، بدون فاصله بعد از دونقطه و با
جداکنندهٔ آویزان در انتها. */}
<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 className="text-[#525252] text-[14px] md:text-[16px] font-medium">
تخصص:
{doctor?.specialties?.map(
(item, idx) =>
`${item.name} ${doctor.specialties.length === idx + 1 ? "" : "|"} `
)}
</h2>
</TextLoading>
<div className="flex items-center justify-start gap-11">
@@ -1,94 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent } from "@testing-library/dom";
// خروجی قبلی کاغذِ [۲۱۰ × ارتفاعِ تصویر] می‌ساخت، یعنی ۲۱۰×۲۶۲٫۵ میلی‌متر. روی هیچ
// پرینتری A4 حساب نمی‌شد. این تست همان قرارداد را قفل می‌کند: یک صفحهٔ A4.
const jsPdfCalls = { ctor: [], addImage: [], save: [] };
vi.mock("jspdf", () => ({
default: class {
constructor(options) {
jsPdfCalls.ctor.push(options);
}
addImage(...args) {
jsPdfCalls.addImage.push(args);
}
save(name) {
jsPdfCalls.save.push(name);
}
},
}));
vi.mock("html-to-image", () => ({
toPng: vi.fn(async () => "data:image/png;base64,AAAA"),
}));
vi.mock("html2canvas", () => ({ default: vi.fn() }));
vi.mock("react-toastify", () => ({
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
}));
// فونت وزیر با fetch به data-url تبدیل می‌شود؛ در jsdom باید تأمین شود وگرنه مسیر
// جایگزینِ html2canvas اجرا می‌شود و تست چیز دیگری را می‌سنجد.
vi.stubGlobal("fetch", vi.fn(async () => ({ blob: async () => new Blob(["x"]) })));
import List from "./List";
// پوستر A4 است: ۱۰۸۰×۱۵۲۷ فیزیکی، که با pixelRatio=2 دو برابر می‌شود.
function posterRef({ width = 1080, height = 1527 } = {}) {
const element = document.createElement("div");
Object.defineProperty(element, "offsetWidth", { value: width });
Object.defineProperty(element, "offsetHeight", { value: height });
element.querySelectorAll = () => [];
return { current: element };
}
async function download(ref) {
render(<List hiddenRef={ref} data={{ name: "مرضیه حسینی" }} qrReady />);
fireEvent.click(screen.getByLabelText("دانلود"));
await waitFor(() => expect(jsPdfCalls.save.length).toBe(1));
}
describe("PDF پوستر", () => {
beforeEach(() => {
jsPdfCalls.ctor = [];
jsPdfCalls.addImage = [];
jsPdfCalls.save = [];
});
it("کاغذ A4 است، نه اندازه‌ای که از نسبت تصویر درمی‌آید", async () => {
await download(posterRef());
expect(jsPdfCalls.ctor[0]).toMatchObject({
orientation: "portrait",
unit: "mm",
format: "a4",
});
});
it("پوستر A4 کل صفحه را پر می‌کند و یک صفحه بیشتر نیست", async () => {
await download(posterRef());
const [, , x, y, width, height] = jsPdfCalls.addImage[0];
expect(jsPdfCalls.addImage).toHaveLength(1);
expect(width).toBeCloseTo(210, 1);
expect(height).toBeCloseTo(297, 0);
expect(x).toBeCloseTo(0, 1);
expect(y).toBeCloseTo(0, 0);
});
/** تور ایمنی: اگر ابعاد پوستر روزی بلندتر شد، تصویر باید کوچک شود نه اینکه از کاغذ بزند بیرون. */
it("پوستر بلندتر از A4 داخل کاغذ جا داده می‌شود، نه بریده", async () => {
await download(posterRef({ width: 1080, height: 2000 }));
const [, , x, y, width, height] = jsPdfCalls.addImage[0];
expect(height).toBeLessThanOrEqual(297);
expect(width).toBeLessThanOrEqual(210);
expect(x).toBeGreaterThan(0);
expect(y).toBeCloseTo(0, 1);
});
});
-66
View File
@@ -1,66 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import DoctorPage from "@/components/doctor";
vi.mock("@/components/doctor/appointmentList", () => ({ default: () => null }));
vi.mock("@/components/doctor/detailDoctor", () => ({ default: () => null }));
vi.mock("@/components/doctor/claim", () => ({ default: () => null }));
vi.mock("@/components/doctor/detailDoctor/Share", () => ({ default: () => null }));
vi.mock("@/components/specialties/detail/Faq", () => ({ default: () => null }));
// همان شش تخصص دکتر محمدباقر جهانتاب.
const JAHANTAB = {
name: "محمدباقر جهانتاب",
specialties: [
{ id: "13", name: "جراحی عمومی", parent_id: null },
{ id: "14", name: "جراحی پلاستیک و زیبایی", parent_id: "13" },
{ id: "167", name: "جراحی لاپاراسکوپی", parent_id: "13" },
{ id: "168", name: "جراح تیروئید", parent_id: "13" },
{ id: "169", name: "جراح گوارش", parent_id: "13" },
{ id: "170", name: "جراحی سرطانها", parent_id: "13" },
],
};
describe("سربرگ صفحهٔ پزشک", () => {
it("فقط تخصص اصلی را نشان می‌دهد، نه هر شش تا", () => {
render(<DoctorPage doctor={JAHANTAB} />);
expect(screen.getByRole("link", { name: "جراحی عمومی" })).toBeInTheDocument();
// بقیه اینجا نمی‌آیند؛ فهرست کامل در بدنهٔ صفحه است و آنجا می‌شکند.
expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
expect(screen.queryByText("جراحی سرطانها")).not.toBeInTheDocument();
});
it("تخصص اصلی به صفحهٔ فرود همان تخصص لینک می‌دهد", () => {
render(<DoctorPage doctor={JAHANTAB} />);
expect(screen.getByRole("link", { name: "جراحی عمومی" })).toHaveAttribute(
"href",
"/specialties/general-surgery"
);
});
it("نام پزشک با عنوان نمایش داده می‌شود", () => {
render(<DoctorPage doctor={JAHANTAB} />);
expect(screen.getByText(/محمدباقر جهانتاب/)).toBeInTheDocument();
});
it("پزشکِ فقط-زیرتخصص، همان زیرتخصص را در سربرگ می‌گیرد", () => {
render(
<DoctorPage
doctor={{ ...JAHANTAB, specialties: [{ id: "169", name: "جراح گوارش", parent_id: "13" }] }}
/>
);
expect(screen.getByRole("link", { name: "جراح گوارش" })).toBeInTheDocument();
});
it("پزشک بدون تخصص سربرگ خالی می‌گیرد و کرش نمی‌کند", () => {
expect(() =>
render(<DoctorPage doctor={{ ...JAHANTAB, specialties: [] }} />)
).not.toThrow();
expect(screen.getByText(/محمدباقر جهانتاب/)).toBeInTheDocument();
});
});
+14 -24
View File
@@ -6,7 +6,6 @@ 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 نداشت، لیست پزشکان با کوئری قدیمی.
@@ -15,28 +14,24 @@ const specialtyHref = (name) => {
return slug ? `/specialties/${slug}` : `/doctors?specialty=${encodeURIComponent(name)}`;
};
function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddressUuids = [], bookingLocations = [], bookingResources = [], slug, faq = [] }) {
const { primary: primarySpecialty } = splitSpecialties(doctor?.specialties);
function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddressUuids = [], bookingLocations = [], slug, faq = [] }) {
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">
{/* این خط یک 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>
)}
<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>
<p className="text-[#525252] text-nowrap mr-1">{doctorTitle(doctor?.name)}</p>
</div>
</CustomLoading>
@@ -52,12 +47,7 @@ function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddres
addresses={addresses}
bookableAddressUuids={bookableAddressUuids}
/>
<AppointmentList
doctor={doctor}
doctorSlug={slug}
bookingLocations={bookingLocations}
bookingResources={bookingResources}
/>
<AppointmentList doctor={doctor} doctorSlug={slug} bookingLocations={bookingLocations} />
</div>
<ClaimProfileSection doctor={doctor} />
<Faq items={faq} />
+13 -23
View File
@@ -2,9 +2,8 @@
import { useEffect, useState } from "react";
import DoctorAvatar from "@/app/component/DoctorAvatar";
import QrImg from "./QrImg";
import SiteLogo from "@/components/common/SiteLogo";
import SiteLogo from "./SiteLogo";
import { getStateInfoClient } from "@/lib/getStateInfoClient";
import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay";
import { doctorTitle } from "@/helper";
import StarDI from "@/components/icons/StarDI";
@@ -22,9 +21,7 @@ function PosterLight({ data, onQrReady }) {
setCity(matchedCity || null);
}, []);
// قرینهٔ پوستر تیره — همان دلیل: برش عددیِ ثابت با نام‌های بلند از کادر بیرون می‌زد.
const { primary, rest } = splitSpecialties(data?.specialties);
const subSpecialties = posterSpecialtyLine(rest, 78);
const specialties = (data?.specialties ?? []).filter((s) => s?.name).slice(0, 4);
const services = (data?.expertise ?? []).filter((s) => s?.name);
const shownServices = services.slice(0, 8);
const moreServices = services.length - shownServices.length;
@@ -47,7 +44,7 @@ function PosterLight({ data, onQrReady }) {
return (
<div
dir="rtl"
className="relative w-[1080px] h-[1527px] overflow-hidden flex flex-col bg-[#F6F9FD] text-center"
className="relative w-[1080px] h-[1350px] overflow-hidden flex flex-col bg-[#F6F9FD] text-center"
>
{/* top brand band */}
<div
@@ -71,9 +68,7 @@ function PosterLight({ data, onQrReady }) {
</div>
</div>
{/* بالا-چین می‌ماند تا آواتار روی نوار بالایی سوار بماند؛ فاصلهٔ اضافیِ کاغذ A4
به‌صورت فضای خالی بالای فوتر می‌نشیند، نه بین آواتار و نوار. */}
<div className="flex-1 px-[72px] pb-[40px] flex flex-col items-center">
<div className="flex-1 px-[72px] flex flex-col items-center">
{/* avatar overlapping band */}
<div className="-mt-[8px] pt-[40px]">
<div
@@ -93,23 +88,18 @@ function PosterLight({ data, onQrReady }) {
{doctorTitle(data?.name)}
</h1>
{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]">
<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]"
>
<span className="text-[#16528C] text-[23px] font-semibold leading-none whitespace-nowrap">
{primary.name}
{s.name}
</span>
</span>
</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>
)}
))}
</div>
{badges.length > 0 && (
<div className="w-full flex flex-wrap justify-center items-center gap-[14px] mt-[22px]">
+14 -27
View File
@@ -2,9 +2,8 @@
import { useEffect, useState } from "react";
import DoctorAvatar from "@/app/component/DoctorAvatar";
import QrImg from "./QrImg";
import SiteLogoBase from "@/components/common/SiteLogo";
import SiteLogoBase from "./SiteLogo";
import { getStateInfoClient } from "@/lib/getStateInfoClient";
import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay";
import { doctorTitle } from "@/helper";
import StarDI from "@/components/icons/StarDI";
@@ -42,10 +41,7 @@ function Poster({ data, onQrReady }) {
setCity(matchedCity || null);
}, []);
// برش بر اساس طول متن است نه عدد ثابت: چهار چیپ با نام‌های بلند به سه ردیف
// می‌رفت، بخش hero را بلند می‌کرد و بخش‌های پایین را از کادر ثابت بیرون می‌انداخت.
const { primary, rest } = splitSpecialties(data?.specialties);
const subSpecialties = posterSpecialtyLine(rest, 78);
const specialties = (data?.specialties ?? []).filter((s) => s?.name).slice(0, 4);
const services = (data?.expertise ?? []).filter((s) => s?.name);
const shownServices = services.slice(0, 8);
const moreServices = services.length - shownServices.length;
@@ -84,7 +80,7 @@ function Poster({ data, onQrReady }) {
return (
<div
dir="rtl"
className="relative w-[1080px] h-[1527px] overflow-hidden flex flex-col p-[64px] text-right"
className="relative w-[1080px] h-[1350px] overflow-hidden flex flex-col p-[64px] text-right"
style={{ background: POSTER_BG }}
>
<div className="pointer-events-none absolute -top-[180px] -left-[180px] w-[520px] h-[520px] rounded-full bg-[#57A6FF]/20 blur-[80px]" />
@@ -132,25 +128,18 @@ function Poster({ data, onQrReady }) {
<h1 className="text-white text-[46px] font-extrabold leading-tight">
{doctorTitle(data?.name)}
</h1>
{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]">
<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">
{primary.name}
{s.name}
</span>
</span>
</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>
<div className="flex flex-wrap gap-[12px] mt-[18px]">
{badges.map((b, i) => (
<span
@@ -176,11 +165,9 @@ function Poster({ data, onQrReady }) {
</section>
{/* body */}
{/* کارت‌ها به هم چسبیده می‌مانند و فاصلهٔ اضافیِ کاغذ A4 بالای فوتر می‌نشیند:
حفرهٔ وسطِ صفحه شبیه محتوای جاافتاده دیده می‌شد. */}
<section className="relative mt-[36px] flex-1 flex flex-col gap-[28px]">
<section className="relative mt-[36px] flex-1">
{shownServices.length > 0 && (
<div className="rounded-[28px] bg-white/6 border border-white/10 p-[32px]">
<div className="rounded-[28px] bg-white/6 border border-white/10 p-[32px] mb-[28px]">
<SectionTitle icon={<ClipBoard />}>خدمات</SectionTitle>
<div className="flex flex-wrap items-center gap-x-[12px] gap-y-[14px]">
{shownServices.map((s, i) => (
-87
View File
@@ -1,87 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import Poster from "@/components/doctor/poster";
import PosterLight from "@/components/doctor/poster/PosterLight";
vi.mock("@/lib/getStateInfoClient", () => ({
getStateInfoClient: () => ({ matchedCity: { site_name: "یاسوج نوبت", domain: "yasuj-nobat.ir" } }),
}));
vi.mock("./QrImg", () => ({ default: () => <div data-testid="qr" /> }));
vi.mock("@/components/doctor/poster/QrImg", () => ({ default: () => <div data-testid="qr" /> }));
// همان شش تخصص دکتر محمدباقر جهانتاب، با همان شکلی که API برمی‌گرداند.
const JAHANTAB = {
name: "محمدباقر جهانتاب",
specialties: [
{ id: "13", name: "جراحی عمومی", parent_id: null },
{ id: "14", name: "جراحی پلاستیک و زیبایی", parent_id: "13" },
{ id: "167", name: "جراحی لاپاراسکوپی", parent_id: "13" },
{ id: "168", name: "جراح تیروئید", parent_id: "13" },
{ id: "169", name: "جراح گوارش", parent_id: "13" },
{ id: "170", name: "جراحی سرطانها", parent_id: "13" },
],
expertise: [],
address: [],
};
describe.each([
["پوستر تیره", Poster],
["پوستر روشن", PosterLight],
])("%s", (_label, Component) => {
it("تخصص والد را به‌عنوان تیتر نشان می‌دهد", () => {
render(<Component data={JAHANTAB} />);
expect(screen.getByText("جراحی عمومی")).toBeInTheDocument();
});
it("زیرتخصص‌ها یک خط متنی‌اند، نه چیپ جداگانه", () => {
render(<Component data={JAHANTAB} />);
// اگر چیپ بودند، هر نام یک گرهٔ متنی مستقل داشت.
expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
expect(screen.getByText(/جراح گوارش/)).toBeInTheDocument();
});
it("مازاد را می‌شمارد و نمی‌برد", () => {
render(<Component data={JAHANTAB} />);
expect(screen.getByText(/تخصص دیگر/)).toBeInTheDocument();
});
// ۱۰۸۰×۱۵۲۷ همان نسبت A4 است (۲۱۰:۲۹۷). هر عدد دیگری یعنی PDF یا حاشیهٔ سفید
// می‌گیرد یا از کاغذ بیرون می‌زند.
it("کادر ثابت ۱۰۸۰×۱۵۲۷ با نسبت A4 و overflow-hidden دست‌نخورده است", () => {
const { container } = render(<Component data={JAHANTAB} />);
const frame = container.firstChild;
expect(frame.className).toContain("w-[1080px]");
expect(frame.className).toContain("h-[1527px]");
expect(frame.className).toContain("overflow-hidden");
expect(1527 / 1080).toBeCloseTo(297 / 210, 2);
});
it("خط زیرتخصص از بودجهٔ کاراکتر نمی‌گذرد", () => {
// jsdom چیدمان را اندازه نمی‌گیرد، پس سرریزِ پیکسلی اینجا اثبات‌شدنی نیست.
// چیزی که اثبات می‌شود همان سازوکاری است که سرریز را می‌بندد: طول متن کراندار.
render(<Component data={JAHANTAB} />);
const line = screen.getByText(/جراح گوارش/);
expect(line.textContent.length).toBeLessThan(120);
});
it("پزشک تک‌تخصص خط زیرتخصص ندارد", () => {
render(
<Component
data={{ ...JAHANTAB, specialties: [{ id: "13", name: "جراحی عمومی", parent_id: null }] }}
/>
);
expect(screen.getByText("جراحی عمومی")).toBeInTheDocument();
expect(screen.queryByText(/تخصص دیگر/)).not.toBeInTheDocument();
});
it("پزشک بدون تخصص کرش نمی‌کند", () => {
expect(() => render(<Component data={{ ...JAHANTAB, specialties: [] }} />)).not.toThrow();
});
});
-75
View File
@@ -1,75 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import Title from "@/components/doctor/detailDoctor/Title";
import ItemAppointment from "@/components/doctor/appointmentList/ItemAppointment";
vi.mock("@/components/doctor/detailDoctor/Share", () => ({ default: () => null }));
// همان شش تخصص دکتر محمدباقر جهانتاب.
const SPECIALTIES = [
{ id: "13", name: "جراحی عمومی", parent_id: null },
{ id: "14", name: "جراحی پلاستیک و زیبایی", parent_id: "13" },
{ id: "167", name: "جراحی لاپاراسکوپی", parent_id: "13" },
{ id: "168", name: "جراح تیروئید", parent_id: "13" },
{ id: "169", name: "جراح گوارش", parent_id: "13" },
{ id: "170", name: "جراحی سرطانها", parent_id: "13" },
];
const DOCTOR = { name: "محمدباقر جهانتاب", specialties: SPECIALTIES, experience: 5 };
describe("بدنهٔ پروفایل — فهرست کامل تخصص‌ها", () => {
it("هر شش تخصص را جدا نشان می‌دهد، نه یک رشتهٔ درهم", () => {
render(<Title doctor={DOCTOR} />);
for (const s of SPECIALTIES) {
expect(screen.getByText(s.name)).toBeInTheDocument();
}
});
it("بعد از «تخصص:» نام نمی‌چسبد", () => {
// باگ قبلی: خروجی «تخصص:جراحی عمومی» بود.
render(<Title doctor={DOCTOR} />);
expect(screen.getByText("تخصص:")).toBeInTheDocument();
expect(screen.queryByText(/تخصص:جراحی/)).not.toBeInTheDocument();
});
it("جداکنندهٔ آویزان در انتها ندارد", () => {
const { container } = render(<Title doctor={DOCTOR} />);
expect(container.textContent).not.toMatch(/\|\s*$/);
expect(container.textContent).not.toContain("|");
});
it("پزشک بدون تخصص فقط برچسب را نشان می‌دهد و کرش نمی‌کند", () => {
expect(() =>
render(<Title doctor={{ ...DOCTOR, specialties: [] }} />)
).not.toThrow();
});
});
describe("ستون نوبت‌دهی — تخصص اصلی و شمارنده", () => {
const turn = { ...DOCTOR, uuid: "u-1", specialties: SPECIALTIES };
it("فقط تخصص اصلی و شمارندهٔ بقیه را نشان می‌دهد", () => {
render(<ItemAppointment turn={turn} doctorSlug="u-1" />);
expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "نمایش 5 تخصص دیگر" })
).toBeInTheDocument();
expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
});
it("جداکنندهٔ آویزان ندارد", () => {
const { container } = render(<ItemAppointment turn={turn} doctorSlug="u-1" />);
expect(container.textContent).not.toContain("|");
});
it("پزشک بدون تخصص کرش نمی‌کند", () => {
expect(() =>
render(<ItemAppointment turn={{ ...turn, specialties: [] }} doctorSlug="u-1" />)
).not.toThrow();
});
});
+13 -2
View File
@@ -1,9 +1,20 @@
import { nextSpecialtyFilter } from "@/helper";
import specialties from "@/data/specialties.json";
import Form from "./form";
function Content({ filter, setFilter, updateData, setDataInURL }) {
const changeSpecialty = (name, value) => {
const newFilter = nextSpecialtyFilter(filter, name, value);
const newFilter = { ...filter, [name]: value };
if (name === "category") {
if (value) {
const filtered = specialties
.filter((item) => item.parent_id)
.filter((item) => String(item.parent_id) === String(value.id));
newFilter.specialty = filtered[0] || null;
} else {
newFilter.specialty = null;
}
}
setFilter(newFilter);
setDataInURL(newFilter);
+2 -6
View File
@@ -24,13 +24,9 @@ function Form({ filter, changeSpecialty, updateData }) {
<CateSelector
name="specialty"
list={childrenList}
updateData={changeSpecialty}
updateData={updateData}
value={filter.specialty}
// خالی‌بودن یعنی کل گروه؛ برچسب همین را می‌گوید تا کاربر فکر نکند
// چیزی را جا انداخته.
label={
filter.category ? `همه تخصص‌های ${filter.category.name}` : "تخصص"
}
label="تخصص"
/>
</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>
);
@@ -1,62 +0,0 @@
import Link from "next/link";
import { childSpecialties, parentSpecialty } from "@/lib/specialtyIndexing";
const CHIP =
"px-[14px] py-[8px] rounded-lg bg-[#FFF] border border-[#EFEFEF] text-[14px] font-medium text-[#525252] hover:bg-[#5559CE] hover:text-[#FFF] hover:border-[#5559CE] transition-all duration-500";
const CHIP_ACTIVE =
"px-[14px] py-[8px] rounded-lg bg-[#5559CE] border border-[#5559CE] text-[14px] font-medium text-[#FFF]";
/**
* ناوبری والد/فرزندِ صفحهٔ تخصص.
*
* تا پیش از این، صفحهٔ «جراحی عمومی» هیچ نشانی از زیرشاخههایش نداشت و صفحهٔ
* «جراح گوارش» راهی به گروهش. کاربر برای دیدن گروه باید URL دستکاری میکرد.
*
* روی صفحهٔ والد، «همه تخصصهای X» حالت جاری است نه یک لینک دیگر: بکاند
* `specialty_id` را به همهٔ نوادگان گسترش میدهد، پس همین صفحه از قبل همهٔ پزشکان
* گروه را نشان میدهد. برچسبِ فعال همین را صریح میکند تا کاربر دنبال گزینه نگردد.
*/
function SpecialtyTree({ specialty }) {
const parent = parentSpecialty(specialty);
const siblingsOf = parent ?? specialty;
const children = childSpecialties(siblingsOf);
if (children.length === 0) return null;
const allHref = `/specialties/${siblingsOf.slug}`;
const isAllActive = !parent;
return (
<nav
aria-label={`زیرتخصص‌های ${siblingsOf.name}`}
className="mt-[20px] flex flex-wrap items-center gap-[8px]"
>
{isAllActive ? (
<span className={CHIP_ACTIVE} aria-current="page">
همه تخصصهای {siblingsOf.name}
</span>
) : (
<Link href={allHref} className={CHIP}>
همه تخصصهای {siblingsOf.name}
</Link>
)}
{children.map((child) => {
const isActive = String(child.id) === String(specialty.id);
return isActive ? (
<span key={child.id} className={CHIP_ACTIVE} aria-current="page">
{child.name}
</span>
) : (
<Link key={child.id} href={`/specialties/${child.slug}`} className={CHIP}>
{child.name}
</Link>
);
})}
</nav>
);
}
export default SpecialtyTree;
@@ -1,90 +0,0 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import specialtiesData from "@/data/specialties.json";
import { childSpecialties, parentSpecialty } from "@/lib/specialtyIndexing";
import SpecialtyTree from "@/components/specialties/detail/SpecialtyTree";
const byName = (name) => specialtiesData.find((s) => s.name === name);
const GENERAL_SURGERY = byName("جراحی عمومی");
const GI = byName("جراح گوارش");
describe("childSpecialties / parentSpecialty", () => {
it("والد فرزندان فعالش را می‌دهد", () => {
const names = childSpecialties(GENERAL_SURGERY).map((s) => s.name);
expect(names).toContain("جراح گوارش");
expect(names).toContain("جراحی پلاستیک و زیبایی");
});
it("برگ فرزندی ندارد", () => {
expect(childSpecialties(GI)).toEqual([]);
});
it("زیرتخصص والدش را می‌دهد، ریشه null", () => {
expect(parentSpecialty(GI)?.id).toBe(GENERAL_SURGERY.id);
expect(parentSpecialty(GENERAL_SURGERY)).toBeNull();
});
it("ورودی تهی کرش نمی‌کند", () => {
expect(childSpecialties(null)).toEqual([]);
expect(parentSpecialty(undefined)).toBeNull();
});
it("ترتیب فرزندان پایدار است", () => {
const once = childSpecialties(GENERAL_SURGERY).map((s) => s.id);
const twice = childSpecialties(GENERAL_SURGERY).map((s) => s.id);
expect(once).toEqual(twice);
});
});
describe("SpecialtyTree", () => {
it("روی صفحهٔ والد، «همه تخصص‌های X» حالت جاری است نه لینک", () => {
render(<SpecialtyTree specialty={GENERAL_SURGERY} />);
const all = screen.getByText(`همه تخصص‌های ${GENERAL_SURGERY.name}`);
expect(all).toHaveAttribute("aria-current", "page");
expect(all.tagName).not.toBe("A");
});
it("روی صفحهٔ والد، همهٔ زیرتخصص‌ها لینک دارند", () => {
render(<SpecialtyTree specialty={GENERAL_SURGERY} />);
const link = screen.getByRole("link", { name: "جراح گوارش" });
expect(link).toHaveAttribute("href", `/specialties/${GI.slug}`);
});
it("روی صفحهٔ زیرتخصص، «همه» لینکِ بازگشت به گروه است", () => {
render(<SpecialtyTree specialty={GI} />);
const back = screen.getByRole("link", {
name: `همه تخصص‌های ${GENERAL_SURGERY.name}`,
});
expect(back).toHaveAttribute("href", `/specialties/${GENERAL_SURGERY.slug}`);
});
it("روی صفحهٔ زیرتخصص، خودش حالت جاری است و خواهرانش لینک", () => {
render(<SpecialtyTree specialty={GI} />);
expect(screen.getByText(GI.name)).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("link", { name: "جراح تیروئید" })).toBeInTheDocument();
});
it("تخصص بدون خواهر و فرزند چیزی رندر نمی‌کند", () => {
const lonely = specialtiesData.find(
(s) => !s.parent_id && childSpecialties(s).length === 0
);
const { container } = render(<SpecialtyTree specialty={lonely} />);
expect(container).toBeEmptyDOMElement();
});
it("ناوبری برچسب دسترسی‌پذیر دارد", () => {
render(<SpecialtyTree specialty={GENERAL_SURGERY} />);
expect(
screen.getByRole("navigation", { name: `زیرتخصص‌های ${GENERAL_SURGERY.name}` })
).toBeInTheDocument();
});
});
@@ -1,66 +0,0 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import specialtiesData from "@/data/specialties.json";
import SpecialtyDetailPage from "@/components/specialties/detail";
const byName = (name) => specialtiesData.find((s) => s.name === name);
const GENERAL_SURGERY = byName("جراحی عمومی");
const GI = byName("جراح گوارش");
const renderPage = (specialty) =>
render(
<SpecialtyDetailPage
specialty={specialty}
specialtyName={specialty.name}
cityName="یاسوج"
doctors={[]}
intro="متن مقدمه"
faq={[]}
origin="https://yasuj-nobat.ir"
slug={specialty.slug}
/>
);
/** حلقه‌های breadcrumd به‌ترتیب. */
const crumbs = () =>
Array.from(
screen.getByRole("navigation", { name: "breadcrumb" }).querySelectorAll("li")
).map((li) => li.textContent.replace(/>/g, "").trim());
describe("breadcrumb صفحهٔ تخصص", () => {
it("صفحهٔ زیرتخصص حلقهٔ والد را دارد", () => {
renderPage(GI);
expect(crumbs()).toEqual(["خانه", "تخصص‌ها", GENERAL_SURGERY.name, GI.name]);
});
it("حلقهٔ والد به صفحهٔ گروه لینک می‌دهد", () => {
renderPage(GI);
expect(
screen.getByRole("link", { name: GENERAL_SURGERY.name })
).toHaveAttribute("href", `/specialties/${GENERAL_SURGERY.slug}`);
});
it("صفحهٔ ریشه حلقهٔ والد اضافه نمی‌گیرد", () => {
renderPage(GENERAL_SURGERY);
expect(crumbs()).toEqual(["خانه", "تخصص‌ها", GENERAL_SURGERY.name]);
});
it("JSON-LD همان حلقه‌ها را دارد — نما و schema واگرا نمی‌شوند", () => {
const { container } = renderPage(GI);
const script = container.querySelector('script[type="application/ld+json"]');
const jsonLd = JSON.parse(script.innerHTML);
expect(jsonLd["@type"]).toBe("BreadcrumbList");
expect(jsonLd.itemListElement.map((i) => i.name)).toEqual([
"خانه",
"تخصص‌ها",
GENERAL_SURGERY.name,
GI.name,
]);
});
});
+1 -11
View File
@@ -1,14 +1,10 @@
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({ specialty, specialtyName, cityName, doctors, intro, faq, origin, slug }) {
const parent = parentSpecialty(specialty);
function SpecialtyDetailPage({ specialtyName, cityName, doctors, intro, faq, origin, slug }) {
return (
<div className="pt-[95px] sm:pt-[120px] padding-responsive">
<Pageguide
@@ -16,10 +12,6 @@ function SpecialtyDetailPage({ specialty, specialtyName, cityName, doctors, intr
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" },
]}
@@ -29,8 +21,6 @@ function SpecialtyDetailPage({ specialty, specialtyName, cityName, doctors, intr
متخصص {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
@@ -1,6 +0,0 @@
{
"alamat-tahooe-mokarrar-dar-bimaran-sartani": "c097ddec",
"eyepressure-glasses-specialized-optometry-assessment": "e2cfc318",
"warning-signs-sleep-elderly": "6a67ded7",
"tashkhis-sari-avarzhe-daru-qatk-masraf": "44424a3c"
}
+1 -46
View File
@@ -844,50 +844,5 @@
"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
@@ -1,154 +0,0 @@
# خطاهای Search Console که باگ نیستند
هر دامنهٔ شهری در Google Search Console چند دستهٔ «مشکل» نشان می‌دهد که خروجیِ **تصمیم‌های
عمدی** سیستم‌اند، نه نقص. این سند مرجع است تا هر بار کسی داشبورد را باز کرد، دوباره دنبال
رفع چیزی نگردد که نباید رفع شود.
سنجش‌های این سند روی `behbahan-nobat.ir` و `yasuj-nobat.ir` در ۵ اوت ۲۰۲۶ گرفته شده‌اند.
> آنچه **واقعاً** باگ بود و جدا رفع می‌شود، اینجا نیست:
> `/doctor/undefined` و یکسان‌بودن صفحهٔ اصلی شهرها.
> ← `.claude/prompt/gsc-undefined-link-and-home-uniqueness.md`
---
## ۱. Excluded by 'noindex' tag
**نمونه:** ۸۰ صفحه در یاسوج، ۱۲ صفحه در بهبهان — عمدتاً `/doctor/<uuid>`.
**چرا عمدی است**
پروفایل پزشکی که از نظام پزشکی وارد شده و خودِ پزشک آن را claim نکرده، محتوای قابل
اتکا نیست. `lib/entityQuality.js` همین را اعمال می‌کند:
```js
export function isUnclaimedDoctor(doctor) {
return doctor?.owner_status !== "claimed";
}
export function isNoindexDoctor(doctor) {
return isThinDoctor(doctor) || isUnclaimedDoctor(doctor);
}
```
همین تابع هم `robots` صفحهٔ پزشک را می‌سازد و هم ورودی `app/sitemap.js` را فیلتر می‌کند، تا
چیزی که noindex است هرگز در sitemap اعلام نشود.
پزشک «بی‌محتوا» هم noindex می‌شود: بدون نام واقعی (شمارهٔ تلفن یا «تست» به‌جای نام)، یا
بدون هیچ تخصص و مکانی.
**چه چیزی این را برمی‌دارد**
فقط claim شدن پروفایل توسط خودِ پزشک، یا کامل شدن نام و تخصص و مکان. **هیچ تغییر کدی
لازم نیست و هیچ تغییر کدی هم نباید انجام شود.**
**چرا `VALIDATE FIX` شکست می‌خورد**
در یاسوج این اعتبارسنجی روی ۲۸ ژوئیه شروع و ۵ اوت `Validation failed` شد. طبیعی است:
اعتبارسنجی یعنی «به گوگل بگو دیگر noindex نیست» در حالی که صفحات عمداً noindex مانده‌اند.
**این دکمه را برای این دسته نزنید.** هر بار شکست می‌خورد و فقط تاریخچهٔ داشبورد را شلوغ می‌کند.
---
## ۲. Alternate page with proper canonical tag
**نمونه در بهبهان:** `/about-us`، `/contact-us`، و دو `/clinic/<uuid>`.
**چرا عمدی است**
استراتژی canonical سه‌دسته است و در بالای `lib/getCanonicalUrl.js` مستند شده:
| دسته | مسیرها | canonical |
|---|---|---|
| C1-a | `/`، `/doctors`، `/clinics`، `/specialties`، `/blogs` | self روی همان دامنهٔ شهری |
| C1-b | `/doctor/[uuid]`، `/clinic/[uuid]`، `/blog/[slug]` | دامنهٔ شهرِ خودِ موجودیت |
| C1-c | `/about-us`، `/contact-us`، `/terms`، `/privacy` | دامنهٔ اصلی `nobat724.com` |
`/about-us` و `/contact-us` روی همهٔ دامنه‌ها محتوای یکسان دارند، پس اعتبارشان عمداً روی
دامنهٔ اصلی تجمیع می‌شود. کلینیکی هم که شهرش بهبهان نیست، canonical‌اش به دامنهٔ شهر خودش
می‌رود تا یک موجودیت روی دو دامنه دو صفحهٔ رقیب نسازد.
`Alternate page with proper canonical tag` در Search Console **گزارش اطلاعاتی** است، نه خطا.
عبارت `proper` در عنوانش یعنی گوگل canonical ما را پذیرفته. این دقیقاً نتیجهٔ مطلوب است.
---
## ۳. Page with redirect
**نمونه:** `http://behbahan-nobat.ir/` — یک صفحه.
ریدایرکت `http` به `https` است. رفتار درست و لازم. کاری ندارد.
---
## ۴. آدرس‌های قدیمی بلاگ که ۴۰۴ می‌دهند
**نمونه در بهبهان:** چهار آدرس با slug انگلیسی، مثل
`/blog/warning-signs-sleep-elderly` و `/blog/alamat-tahooe-mokarrar-dar-bimaran-sartani`.
**چه اتفاقی افتاده**
فرمت slug مقاله‌ها عوض شده. slug امروز فارسی است با پسوند هگز هشت‌کاراکتری — نمونهٔ
واقعی از API:
```
آلرژی-دارویی-تشخیص-علائم-خطرناک-و-ضرورت-مراجعه-به-پزشک-58d2e880
```
slugهای انگلیسیِ قبلی دیگر وجود ندارند و **نگاشت قدیم‌به‌جدید هیچ‌جا ذخیره نشده**.
**تصمیم: ۴۰۴ پذیرفته می‌شود**
۴۰۴ پاسخ صحیح برای محتوایی است که دیگر با آن نشانی وجود ندارد. گوگل خودش این آدرس‌ها را
از ایندکس حذف می‌کند. ساختن redirect نیازمند پیدا کردن دستیِ معادل هر کدام است و فقط
چهار آدرس در میان است.
بررسی شد که خودِ رفتار ۴۰۴ سالم است — سه نشانیِ ناموجود، هر سه `404`:
```
/blog/warning-signs-sleep-elderly → 404
/blog/definitely-does-not-exist-xyz-123 → 404
/blog/alamat-tahooe-mokarrar-dar-bimaran-sartani → 404
```
**اگر روزی slug دوباره عوض شد**, این‌بار قبلش نگاشت را نگه دارید. تکرار همین وضعیت با
تعداد بیشتر، دیگر با «۴۰۴ بپذیر» حل نمی‌شود.
---
## ۵. Discovered — currently not indexed
**نمونه:** ۷۳ آدرس در یاسوج — صفحات تخصص، کلینیک، پزشک و بلاگ.
**معنایش**
گوگل این آدرس‌ها را پیدا کرده اما هنوز crawl نکرده. تقریباً همیشه یعنی سایت در نظر گوگل
اولویت crawl پایینی دارد، نه اینکه صفحه خراب باشد.
**چرا اینجا رخ می‌دهد**
دو عامل، و هر دو در حال رفع‌اند یا عمدی‌اند:
۱. سهم بزرگی از صفحات دامنه noindex است (بند ۱). سایتی که بیشتر صفحاتش noindex است،
سیگنال ضعیف‌تری برای صرف بودجهٔ crawl می‌دهد.
۲. صفحهٔ اصلی شهرها تا پیش از این ۹۹٪ شبیه هم بود. این مورد **باگ است** و جدا رفع می‌شود
(پرامپت بالای این سند).
**کاری که نباید کرد**
آدرس‌ها را دستی و دسته‌ای برای ایندکس submit نکنید. مشکل کمبودِ کشف نیست؛ اعلام دوبارهٔ
همان آدرس‌ها چیزی را جلو نمی‌اندازد.
---
## خلاصهٔ تصمیم
| دستهٔ Search Console | وضعیت | اقدام |
|---|---|---|
| Excluded by 'noindex' tag | عمدی | هیچ. `Validate fix` نزنید |
| Alternate page with proper canonical | عمدی و مطلوب | هیچ |
| Page with redirect | طبیعی | هیچ |
| Not found (404) — بلاگ‌های قدیمی | پذیرفته‌شده | هیچ |
| Not found (404) — `/doctor/undefined` | **باگ** | پرامپت جداگانه |
| Duplicate, Google chose different canonical | **باگ محتوایی** | پرامپت جداگانه |
| Discovered — currently not indexed | پیامد دو مورد بالا | بعد از رفع آن‌ها دوباره سنجیده شود |
+1 -21
View File
@@ -411,25 +411,5 @@
"409": "Community 409",
"410": "Community 410",
"411": "Community 411",
"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"
"412": "Community 412"
}
+67 -113
View File
@@ -1,16 +1,16 @@
# Graph Report - nobat724_front (2026-08-09)
# Graph Report - nobat724_front (2026-07-30)
## Corpus Check
- 669 files · ~597,587 words
- 642 files · ~582,216 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 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)
- 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)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `002f388a`
- Built from commit: `8a24579a`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -139,7 +139,6 @@
- [[_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]]
@@ -184,52 +183,35 @@
- [[_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()` - 30 edges
1. `getStateInfo()` - 29 edges
2. `🏥 نوبت724 (Nobat724) - سیستم نوبت‌دهی آنلاین پزشکی` - 19 edges
3. `تقسیم ممیزی SEO به پرامپت‌های کوچک اجرایی — nobat724` - 18 edges
4. `Blog()` - 17 edges
5. `imageUrl()` - 16 edges
4. `imageUrl()` - 16 edges
5. `Blog()` - 15 edges
6. `Clinic()` - 13 edges
7. `Doctor()` - 13 edges
8. `generateMetadata()` - 12 edges
7. `Doctor()` - 12 edges
8. `generateMetadata()` - 10 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
- `SpecialtyDetail()` --calls--> `buildSpecialtyFaq()` [INFERRED]
app/specialties/[slug]/page.js → lib/specialtyContent.js
- `AboutUsPage()` --calls--> `getStateInfo()` [INFERRED]
components/aboutUs/index.js → lib/getStateInfo.js
- `ContactUsPage()` --calls--> `getStateInfo()` [INFERRED]
components/contactUs/index.js → lib/getStateInfo.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 (433 total, 47 thin omitted)
## Communities (413 total, 36 thin omitted)
### Community 0 - "Community 0"
Cohesion: 0.05
@@ -248,12 +230,12 @@ Cohesion: 0.23
Nodes (16): getCurrentDomain(), robots(), cityFilterParams(), fetchAllPages(), getBlogUrls(), getCityScope(), getClinicUrls(), getCurrentDomain() (+8 more)
### Community 4 - "Community 4"
Cohesion: 0.06
Nodes (29): ContentDetail(), About(), ListBime(), Services(), Specialties(), TextDetail(), listTab, AddComment() (+21 more)
Cohesion: 0.05
Nodes (33): ContentDetail(), About(), ListBime(), Services(), Specialties(), TextDetail(), listTab, AddComment() (+25 more)
### Community 5 - "Community 5"
Cohesion: 0.05
Nodes (37): bookingState(), DAY_NAMES, formatJalali(), AppointmentList(), ItemAppointment(), AboutDcotor(), ClaimProfileSection(), currentUserMobile() (+29 more)
Cohesion: 0.07
Nodes (26): bookingState(), DAY_NAMES, formatJalali(), AppointmentList(), ItemAppointment(), AboutDcotor(), ClaimProfileSection(), currentUserMobile() (+18 more)
### Community 6 - "Community 6"
Cohesion: 0.22
@@ -288,16 +270,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.08
Nodes (22): Head(), AboutUsPage(), ourServices, Services(), Call(), Links(), Connect(), ContentContact() (+14 more)
Cohesion: 0.15
Nodes (10): Call(), Links(), Connect(), ContentContact(), FieldLabel(), ContactUsPage(), ArrowLeftC(), CallC() (+2 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.06
Nodes (30): بازگرداندن صفحهٔ اصلی، رفع ۴۰۴ اسلاگ فارسی، سه اصلاح صفحهٔ مقاله، و دو اصلاح «درباره ما», برچسب شهر, تگ‌های بدون لینک, زمینه, صفحهٔ اصلی, صفحهٔ تخصص — دو مصرف متفاوت از یک param, عکس مطالب مرتبط, فایل‌های مرتبط (+22 more)
Cohesion: 0.25
Nodes (6): Head(), AboutUsPage(), ourServices, Services(), TickOrangeA(), UnderlineLG()
### Community 17 - "Community 17"
Cohesion: 0.11
@@ -348,8 +330,8 @@ Cohesion: 0.12
Nodes (16): دیپلوی nobat724_front روی Liara (پلتفرم Next.js), زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, پروژه (+8 more)
### Community 29 - "Community 29"
Cohesion: 0.11
Nodes (12): generateMetadata(), Blogs(), generateMetadata(), generateMetadata(), Appointment(), metadata, fetchSiteContext(), getStateInfo() (+4 more)
Cohesion: 0.13
Nodes (11): generateMetadata(), Blogs(), generateMetadata(), Appointment(), metadata, fetchSiteContext(), getStateInfo(), siteContextCache (+3 more)
### Community 30 - "Community 30"
Cohesion: 0.12
@@ -412,8 +394,8 @@ Cohesion: 0.14
Nodes (13): `hours/List.js`, آبجکت اسلات (از `adaptSlots` / API), تأیید و سخت‌سازی غیرفعال‌بودن اسلات‌های گذشته در صفحه‌ی نوبت, زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی (کد واقعی) (+5 more)
### Community 45 - "Community 45"
Cohesion: 0.36
Nodes (11): generateMetadata(), blogCover(), imageUrl(), domainScopeCityId(), findCityById(), isBlogVisibleOnDomain(), getBlogCanonicalOrigin(), Blog() (+3 more)
Cohesion: 0.20
Nodes (20): generateMetadata(), generateMetadata(), generateMetadata(), DoctorPage(), blogCover(), doctorTitle(), imageUrl(), domainScopeCityId() (+12 more)
### Community 46 - "Community 46"
Cohesion: 0.22
@@ -456,8 +438,8 @@ Cohesion: 0.27
Nodes (5): Field(), ItemUser(), Replies(), SendAnswer(), timeAgo()
### Community 56 - "Community 56"
Cohesion: 0.23
Nodes (6): DoctorAvatar(), getInitials(), SpecialtyChips(), CircularLoading(), CustomLoading(), TextLoading()
Cohesion: 0.27
Nodes (5): DoctorAvatar(), getInitials(), CircularLoading(), CustomLoading(), TextLoading()
### Community 57 - "Community 57"
Cohesion: 0.18
@@ -516,12 +498,12 @@ Cohesion: 0.20
Nodes (7): AutoComplete(), ButtonFilter(), Content(), ModalSearchCity(), AutoSearch(), FilterButton(), SearchBar()
### Community 71 - "Community 71"
Cohesion: 0.20
Nodes (8): degree, Form(), gender, degree, Form(), gender, allOfCategoryOption(), filterList()
Cohesion: 0.22
Nodes (7): degree, Form(), gender, degree, Form(), gender, filterList()
### Community 72 - "Community 72"
Cohesion: 0.26
Nodes (13): generateMetadata(), findDomainByCityId(), buildCanonicalPath(), buildCanonicalQuery(), buildCanonicalUrl(), buildFilteredListingCanonical(), buildMainCanonicalUrl(), CANONICAL_QUERY_KEYS (+5 more)
Cohesion: 0.24
Nodes (15): generateMetadata(), findDomainByCityId(), buildCanonicalPath(), buildCanonicalQuery(), buildCanonicalUrl(), buildFilteredListingCanonical(), buildMainCanonicalUrl(), CANONICAL_QUERY_KEYS (+7 more)
### Community 73 - "Community 73"
Cohesion: 0.32
@@ -556,8 +538,8 @@ Cohesion: 0.24
Nodes (5): Head(), Article(), LatestArticles(), Title(), ItemTitle()
### Community 86 - "Community 86"
Cohesion: 0.52
Nodes (5): generateMetadata(), decodeRouteParam(), findSpecialty(), getDoctors, SpecialtyDetail()
Cohesion: 0.27
Nodes (10): generateMetadata(), bodyVariants(), buildSpecialtyFaq(), buildSpecialtyIntro(), closingVariants(), openingVariants(), pick(), findSpecialty() (+2 more)
### Community 87 - "Community 87"
Cohesion: 0.33
@@ -632,12 +614,8 @@ Cohesion: 0.15
Nodes (12): اصلاح بخش «اطلاعات تماس» صفحه کلینیک, زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, پروژه (+4 more)
### Community 140 - "Community 140"
Cohesion: 0.53
Nodes (4): Pageguide(), absoluteUrl(), buildBreadcrumbJsonLd(), normalizeBreadcrumb()
### Community 188 - "Community 188"
Cohesion: 0.09
Nodes (22): انتخاب اجباری اولین زیرتخصص, زمینه, فایل‌های مرتبط, مشکل / هدف, معیار پذیرش, نمایش چندتخصصی پزشک در کارت، فیلترها و پوستر, نکات مهم, وضعیت فعلی (+14 more)
Cohesion: 0.36
Nodes (6): RootLayout(), Pageguide(), absoluteUrl(), buildBreadcrumbJsonLd(), normalizeBreadcrumb(), safeJsonLd()
### Community 354 - "Community 354"
Cohesion: 0.11
@@ -664,16 +642,12 @@ Cohesion: 0.29
Nodes (6): Doctors(), buildDoctorParams(), normalizeDoctorNameQuery(), QueryForDoctorsClinicReq(), QueryForDoctorsReq(), buildSearchUrl()
### Community 370 - "Community 370"
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()
Cohesion: 0.25
Nodes (5): ProvinceContext, ProvinceProvider(), Probe(), useProvince(), SubmitData()
### Community 372 - "Community 372"
Cohesion: 0.24
Nodes (13): generateMetadata(), generateMetadata(), toE164Ir(), extractEntityCityId(), hasRealName(), isNoindexDoctor(), isThinClinic(), isThinDoctor() (+5 more)
Cohesion: 0.48
Nodes (6): hasRealName(), isNoindexDoctor(), isThinClinic(), isThinDoctor(), isUnclaimedDoctor(), PLACEHOLDER_NAMES
### Community 373 - "Community 373"
Cohesion: 0.12
@@ -715,10 +689,6 @@ 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()
@@ -735,13 +705,17 @@ 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.36
Nodes (5): ClinicsPage(), DoctorsPage(), resolveCityDisplayName(), isRootCity(), ListDoctors()
Cohesion: 0.21
Nodes (10): ClinicsPage(), DoctorsPage(), resolveCityDisplayName(), buildClinicsIntro(), buildDoctorsIntro(), CLINIC_INTROS(), DOCTOR_INTROS(), pick() (+2 more)
### Community 403 - "Community 403"
Cohesion: 0.29
@@ -752,60 +726,40 @@ Cohesion: 0.10
Nodes (19): زمینه, صفحهٔ بلاگ: بردکرامب دسته‌بندی، فیلتر دسته‌بندی، و اصلاح ستون «مطالب مرتبط», فایل‌های مرتبط, مشکل / هدف, معیار پذیرش, نکات مهم, وضعیت فعلی, وظایف (+11 more)
### Community 408 - "Community 408"
Cohesion: 0.13
Nodes (12): Clinics(), generateMetadata(), buildClinicParams(), buildClinicsIntro(), CLINIC_INTROS(), countSentence(), pick(), isMaintenanceError() (+4 more)
Cohesion: 0.17
Nodes (8): Clinics(), generateMetadata(), buildClinicParams(), isMaintenanceError(), isNextRedirectError(), axiosInstance, fetchReq(), getClinicDoctors()
### Community 409 - "Community 409"
Cohesion: 0.22
Nodes (9): scripts, build, dev, lint, prebuild, start, test, test:cov (+1 more)
Cohesion: 0.25
Nodes (8): scripts, build, dev, lint, start, test, test:cov, test:watch
### Community 410 - "Community 410"
Cohesion: 0.33
Nodes (5): engines, node, name, private, version
### Community 411 - "Community 411"
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
Cohesion: 0.67
Nodes (3): IDS_WITH_ACTIVE_CHILDREN, isCategorySpecialty(), isIndexableSpecialty()
## Knowledge Gaps
- **1035 isolated node(s):** `metadata`, `DOCTOR`, `fallbackLabels`, `root`, `gi` (+1030 more)
- **945 isolated node(s):** `metadata`, `fallbackLabels`, `MaterialUISwitch`, `fixedIconData`, `metadata` (+940 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **47 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
- **36 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 `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._
- **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._
- **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

Some files were not shown because too many files have changed in this diff Show More