Compare commits
218
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e86f115d3 | ||
|
|
54bebbd41a | ||
|
|
1668ef8890 | ||
|
|
002f388ac8 | ||
|
|
9cfbd2e002 | ||
|
|
e8a073cae6 | ||
|
|
da3f1e5458 | ||
|
|
72beb8b591 | ||
|
|
c118791911 | ||
|
|
b494473e40 | ||
|
|
79747c443c | ||
|
|
56e264e44c | ||
|
|
94c22de8dd | ||
|
|
29881eede0 | ||
|
|
8a24579a50 | ||
|
|
e87a0bae9b | ||
|
|
eb83762016 | ||
|
|
533449c504 | ||
|
|
19ae08c04a | ||
|
|
cea8982e6d | ||
|
|
2f8dec0fb8 | ||
|
|
2b68ad46b7 | ||
|
|
d02ae8bff0 | ||
|
|
b8514e64cf | ||
|
|
9053681375 | ||
|
|
f46ba03422 | ||
|
|
7bbfe9a698 | ||
|
|
42b7fa5a98 | ||
|
|
3b6698dca0 | ||
|
|
0a2a2bfba3 | ||
|
|
cd32f6c8bd | ||
|
|
1986356c91 | ||
|
|
ccbd028fee | ||
|
|
a39b90c88c | ||
|
|
ac3a84d55d | ||
|
|
984511653b | ||
|
|
30c3fe8637 | ||
|
|
9011b230b3 | ||
|
|
daf38c8631 | ||
|
|
18484f974d | ||
|
|
1522ee8b73 | ||
|
|
10b5a19576 | ||
|
|
e8a7bdf2fc | ||
|
|
4c8377f439 | ||
|
|
b3ffba82d4 | ||
|
|
29b8819021 | ||
|
|
672a7d89bf | ||
|
|
97e990982f | ||
|
|
8eb55679b2 | ||
|
|
488e5eb0a4 | ||
|
|
4d7b87ee1a | ||
|
|
88782e70c2 | ||
|
|
21f5f07bc6 | ||
|
|
c64a4a7d69 | ||
|
|
fd48b48613 | ||
|
|
36816eded2 | ||
|
|
dfcf62735b | ||
|
|
b37096048c | ||
|
|
f4dd73e55e | ||
|
|
0eb172517f | ||
|
|
8d0902f35c | ||
|
|
eba0c6a5ba | ||
|
|
effa264027 | ||
|
|
6f9640cea6 | ||
|
|
165a0a2240 | ||
|
|
d5845bf84d | ||
|
|
07d6526f74 | ||
|
|
58f05f8f4f | ||
|
|
42ac6645d5 | ||
|
|
ce05e464c4 | ||
|
|
8f1c63d3b1 | ||
|
|
f9c1f2994e | ||
|
|
ed985f55fb | ||
|
|
c007ffa2b3 | ||
|
|
813225ff6d | ||
|
|
f584f581e5 | ||
|
|
4fed9a8c57 | ||
|
|
d8ab26b4b7 | ||
|
|
2fd0eec156 | ||
|
|
58a69e3345 | ||
|
|
9a3e84cc85 | ||
|
|
778876b1f7 | ||
|
|
ab2ab0dea2 | ||
|
|
a56b7e8de5 | ||
|
|
ee47b535d8 | ||
|
|
082f54cf4d | ||
|
|
92b8f474c1 | ||
|
|
156d97ac03 | ||
|
|
a4e4228dc1 | ||
|
|
73c10f53ec | ||
|
|
df7fbb7530 | ||
|
|
beb050aee2 | ||
|
|
2fbd24b4aa | ||
|
|
ab1102ec1c | ||
|
|
3d861c1f2d | ||
|
|
e8e3d7bfd8 | ||
|
|
1d2ad48138 | ||
|
|
08dd9e408b | ||
|
|
374a9d5350 | ||
|
|
4df9439087 | ||
|
|
29e938afcb | ||
|
|
a9fcfea12a | ||
|
|
f6475d1a7d | ||
|
|
b04bb45ca1 | ||
|
|
522caf6b1b | ||
|
|
39c09bfcd5 | ||
|
|
3c019932bd | ||
|
|
199c843b08 | ||
|
|
3344a2f77a | ||
|
|
97cc4fdc69 | ||
|
|
41f7739726 | ||
|
|
3fdd8b3e8b | ||
|
|
aa75fb858f | ||
|
|
e00db96150 | ||
|
|
c879c21163 | ||
|
|
3336826884 | ||
|
|
a5c15c54a1 | ||
|
|
7694ca85a5 | ||
|
|
f5e06cfc13 | ||
|
|
90d4a871f5 | ||
|
|
5bec4848a2 | ||
|
|
79f7df2e4d | ||
|
|
e6b55c6bad | ||
|
|
e83a666adb | ||
|
|
9e67ce4872 | ||
|
|
e5c9e2a312 | ||
|
|
9e5da8f19e | ||
|
|
e6158cfe84 | ||
|
|
1b76c3846b | ||
|
|
430cac6b4c | ||
|
|
4252df21f7 | ||
|
|
ee1192b5b9 | ||
|
|
c316d22160 | ||
|
|
de9e6b7678 | ||
|
|
82700a60c1 | ||
|
|
ac15552cc1 | ||
|
|
09373c46d6 | ||
|
|
f87110c277 | ||
|
|
537f3e47d5 | ||
|
|
cc6b42c1a5 | ||
|
|
ce07b4d1b1 | ||
|
|
57e341b7b3 | ||
|
|
a83e352dd8 | ||
|
|
12d2e2e3f1 | ||
|
|
f668d1b0a7 | ||
|
|
03eb483486 | ||
|
|
1841a16b4d | ||
|
|
bb559df1a9 | ||
|
|
35e8b65342 | ||
|
|
cbae19d270 | ||
|
|
cb13ba3f45 | ||
|
|
2fc74a664e | ||
|
|
e0d729dcf1 | ||
|
|
609479919a | ||
|
|
9dd71ab2f5 | ||
|
|
8e7d10c859 | ||
|
|
cb9d9beaab | ||
|
|
a8c3a57114 | ||
|
|
93b2710ae1 | ||
|
|
7462e69641 | ||
|
|
704a514542 | ||
|
|
06a877a725 | ||
|
|
6ec2f5a069 | ||
|
|
6e1550c610 | ||
|
|
00e9ab5cf8 | ||
|
|
194ffd889c | ||
|
|
a19058d9a2 | ||
|
|
da05a4e13c | ||
|
|
ea2c0c29ec | ||
|
|
f6a3082ac8 | ||
|
|
8b23f1f831 | ||
|
|
e9136ea42b | ||
|
|
4dca516598 | ||
|
|
fdb5866562 | ||
|
|
ecb77fcfa8 | ||
|
|
344285dce5 | ||
|
|
69e591f03e | ||
|
|
bdf453a3ab | ||
|
|
7949da9ba9 | ||
|
|
59d29cd7c7 | ||
|
|
7d65239615 | ||
|
|
12917cd900 | ||
|
|
a79a6c0629 | ||
|
|
e7db7c705b | ||
|
|
caca3fda97 | ||
|
|
21676857b4 | ||
|
|
41f950985b | ||
|
|
95cbee48ab | ||
|
|
c80f002670 | ||
|
|
8a87d9cf92 | ||
|
|
224c51069c | ||
|
|
0a95e99efa | ||
|
|
cec6b51f23 | ||
|
|
96b1684b82 | ||
|
|
fbb2279b49 | ||
|
|
83b2ec21b7 | ||
|
|
d114730768 | ||
|
|
9912ae06ca | ||
|
|
08f5829a22 | ||
|
|
eb3cb6ca10 | ||
|
|
08107d6ef2 | ||
|
|
969e915815 | ||
|
|
e820cc88ee | ||
|
|
da3f20e07d | ||
|
|
65b3f56530 | ||
|
|
232e7eaeb2 | ||
|
|
96f4126c0b | ||
|
|
779113bb19 | ||
|
|
cf705a894e | ||
|
|
1196aa64c6 | ||
|
|
81121ad7d7 | ||
|
|
61c21136d2 | ||
|
|
ba32cd4192 | ||
|
|
7a8700b09b | ||
|
|
dcfdedd93c | ||
|
|
0e112d2299 | ||
|
|
811882ea05 | ||
|
|
e13d1c2eeb |
@@ -0,0 +1,118 @@
|
|||||||
|
# تطبیق سایت عمومی با تغییرات API برنچ `backend-audit`
|
||||||
|
|
||||||
|
> ✅ **حلشده (Path A) — هیچ تغییری در `nobat724_front` لازم نیست.**
|
||||||
|
> مشکل rotation با **نرمکردن backend** حل شد: در `clinicpro` (commit روی `backend-audit`) چرخش/باطلسازی single-use از `/oauth/token/refresh` برداشته شد و توکن refresh دوباره قابلاستفاده شد (فقط چک کاربر معلق `status!=1` باقی ماند). پس `lib/serverToken.js` بدون تغییر کار میکند.
|
||||||
|
> تنها نکتهی باقیمانده **اختیاری** است: ۵۰-cap نظرات (وظیفه ۲ پایین) — اگر ۵۰ نظر کافی است، کاری لازم نیست.
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی). پرامپت همتا برای Admin SPA: `clinicpro/.claude/prompt/admin-spa-adapt-backend-audit.md`. منبع تغییرات: برنچ `backend-audit` در `clinicpro` (۳۳ commit).
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
ممیزی بکاند چند endpoint را تغییر داد. مقدارهای wire کدهای خطا حفظ شدهاند، ولی **یک تغییر رفتارِ شکننده** برای سایت عمومی وجود دارد (rotation توکن refresh) و یک تغییر کماهمیت (صفحهبندی نظرات). این پرامپت مصرفکنندههای `nobat724_front` را اصلاح میکند.
|
||||||
|
|
||||||
|
## جدول تغییرات API مرتبط با سایت عمومی
|
||||||
|
|
||||||
|
| Endpoint | تغییر | مصرف در nobat724 | شدت |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `POST /oauth/token/refresh` | refresh_token اکنون **یکبارمصرف** است و در هر فراخوانی **چرخش** میکند؛ کاربر معلق `401` | `lib/serverToken.js` | 🔴 **شکننده** |
|
||||||
|
| `GET /api/v1/comments/{uuid}` | `data.meta` افزوده شد، پیشفرض **۵۰ نظر** (قبلاً همه) | `app/doctor/[slug]/page.js`, `services/response.js` `getDoctorComments` | 🟡 کم |
|
||||||
|
| `GET /api/v1/insurance/{id}`, `GET /clinic-pro/doctor-address/{id}`, `appointment-settings/{...}` | حالا owner/admin (`403`) | مصرف نمیشود | ⚪ بدون اثر |
|
||||||
|
| کدهای خطای legacy (M21) | مقدار wire بدون تغییر | — | ⚪ بدون اثر |
|
||||||
|
|
||||||
|
## 🔴 مشکل اصلی: rotation توکن refresh (`POST /oauth/token/refresh`)
|
||||||
|
|
||||||
|
### رفتار جدید backend
|
||||||
|
هر فراخوانی `/oauth/token/refresh`:
|
||||||
|
1. توکن ارائهشده را **باطل** میکند (single-use)،
|
||||||
|
2. یک جفت `access_token` + **`refresh_token` جدید** صادر میکند،
|
||||||
|
3. کاربر با `status != 1` → `401`.
|
||||||
|
|
||||||
|
پاسخ:
|
||||||
|
```json
|
||||||
|
{ "access_token": "…", "refresh_token": "<توکن جدید — با قبلی فرق دارد>", "token_type": "Bearer", "expires_in": 900, "refresh_token_expires_in": 2592000 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### وضعیت فعلی nobat724 (کد واقعی — `lib/serverToken.js`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
export async function getServerAccessToken() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const refreshToken = cookieStore.get("refresh_token")?.value;
|
||||||
|
if (!refreshToken) return null;
|
||||||
|
try {
|
||||||
|
const res = await axiosInstance.post(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/oauth/token/refresh`,
|
||||||
|
{ refresh_token: refreshToken },
|
||||||
|
{ headers: { "Content-Type": "application/json", Authorization: "" } }
|
||||||
|
);
|
||||||
|
return res.data?.access_token ?? null; // ⚠️ فقط access_token خوانده میشود
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### چرا میشکند
|
||||||
|
- این تابع **در هر بار رندر صفحه** صدا زده میشود (مثلاً `app/dashboard/page.js`).
|
||||||
|
- `refresh_token` چرخشیافتهی جدید را **ذخیره نمیکند** و توکن قدیمیِ کوکی پس از اولین refresh **باطل** شده است.
|
||||||
|
- نتیجه: refresh اول OK → ناوبری بعدی همان کوکی باطل را میفرستد → `401` → `null` → **redirect به login** (خروج عملی کاربر).
|
||||||
|
- **محدودیت Next.js:** `getServerAccessToken()` از یک **Server Component** صدا زده میشود؛ Server Componentها **نمیتوانند کوکی ست کنند** (فقط Server Action / Route Handler / middleware میتوانند). پس ذخیرهی توکن چرخشیافته در همین تابع ممکن نیست.
|
||||||
|
|
||||||
|
### وظیفه ۱ — حل rotation (یکی از دو مسیر؛ مسیر A توصیه میشود)
|
||||||
|
|
||||||
|
> این یک **تصمیم cross-repo** است. قبل از پیادهسازی، با تیم بکاند هماهنگ کن.
|
||||||
|
|
||||||
|
**مسیر A (توصیهشده — تغییر در backend):**
|
||||||
|
چون سایت در هر رندر refresh میزند، single-use rotation با این معماری ناسازگار است. بهترین کار: در `clinicpro` `AuthController::refreshToken` **بخشِ چرخش/باطلسازی را بردار** و فقط **چک status** (کاربر معلق `401`) را نگهدار. در این حالت `serverToken.js` نیازی به تغییر ندارد. (یک پرامپت backend جدا برای این کار بساز.)
|
||||||
|
|
||||||
|
**مسیر B (تغییر در nobat724 — اگر rotation باید بماند):**
|
||||||
|
refresh را به جایی منتقل کن که **اجازهی ستکردن کوکی دارد**:
|
||||||
|
- یک **Route Handler** (`app/api/refresh/route.js`) یا **middleware** که `/oauth/token/refresh` را صدا بزند و **`res.data.refresh_token` و `access_token` جدید را در کوکی بنویسد**، سپس صفحات بهجای فراخوانی مستقیم، از این مسیر استفاده کنند.
|
||||||
|
- نمونه (Route Handler):
|
||||||
|
```js
|
||||||
|
// app/api/refresh/route.js
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
export async function POST() {
|
||||||
|
const jar = await cookies();
|
||||||
|
const rt = jar.get("refresh_token")?.value;
|
||||||
|
if (!rt) return Response.json({ ok: false }, { status: 401 });
|
||||||
|
const r = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/oauth/token/refresh`, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ refresh_token: rt }),
|
||||||
|
});
|
||||||
|
if (!r.ok) return Response.json({ ok: false }, { status: 401 });
|
||||||
|
const d = await r.json();
|
||||||
|
jar.set("access_token", d.access_token, { httpOnly: true, secure: true, sameSite: "lax", maxAge: d.expires_in });
|
||||||
|
jar.set("refresh_token", d.refresh_token, { httpOnly: true, secure: true, sameSite: "lax", maxAge: d.refresh_token_expires_in }); // ← توکن چرخشیافته
|
||||||
|
return Response.json({ ok: true, access_token: d.access_token });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- بعد `getServerAccessToken` (در Server Component، read-only) فقط کوکی `access_token` معتبر فعلی را بخواند؛ تجدید توسط middleware/route قبل از رندر انجام شود.
|
||||||
|
|
||||||
|
> **مهم:** هر کجای دیگر `nobat724` که توکن refresh ذخیره/استفاده میشود را هم بررسی کن (مثلاً جریان login که کوکیها را ست میکند) تا توکن چرخشیافتهی جدید همیشه جایگزین قدیمی شود.
|
||||||
|
|
||||||
|
## 🟡 وظیفه ۲ — صفحهبندی نظرات (`GET /api/v1/comments/{uuid}`)
|
||||||
|
|
||||||
|
### وضعیت فعلی (`app/doctor/[slug]/page.js`)
|
||||||
|
```js
|
||||||
|
const resComments = await fetch(`${API_URL}/api/v1/comments/${doctor.uuid}`, { cache: "no-store" });
|
||||||
|
const jsonComments = resComments.ok ? await resComments.json() : null;
|
||||||
|
comments = jsonComments?.data?.data; // آرایه — هنوز کار میکند
|
||||||
|
```
|
||||||
|
|
||||||
|
### تغییر
|
||||||
|
پاسخ حالا `{ data: { data: [...], meta: { totalRecords, totalPages, currentPage } } }` است و **پیشفرض ۵۰ نظر** برمیگرداند (قبلاً همه). `data.data` (آرایه) دستنخورده → کد فعلی **نمیشکند**، فقط حداکثر ۵۰ نظر نشان میدهد.
|
||||||
|
|
||||||
|
### وظیفه
|
||||||
|
- اگر برای صفحهی پزشک ۵۰ نظر کافی است (بهعلاوهی schema/Review)، **هیچ تغییری لازم نیست** (فقط آگاه باش).
|
||||||
|
- اگر همهی نظرات لازم است: یا `?limit=100` بفرست، یا «نمایش بیشتر»/صفحهبندی با `data.meta.totalPages` اضافه کن. `getDoctorComments` در `services/response.js` را هم در صورت نیاز با پارامتر `page`/`limit` تطبیق بده.
|
||||||
|
- اگر برای SEO/`Review` schema از تعداد کل نظر استفاده میکنی، آن را از `data.meta.totalRecords` بخوان (نه `length` آرایهی ۵۰تایی).
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- `lib/serverToken.js` در Server Component اجرا میشود → نمیتواند کوکی ست کند؛ ستکردن کوکی فقط در Route Handler/Server Action/middleware. (مسیر A این مشکل را کلاً حذف میکند.)
|
||||||
|
- `clinic-pro-tauri` تحت تأثیر rotation **نیست** چون از `/oauth/token` (grant_type=refresh_token، OAuth bundle) استفاده میکند نه `/oauth/token/refresh`.
|
||||||
|
- کدهای خطا (M21) مقدار wire ثابت دارند؛ هر منطقی که روی رشتهی `code` switch میکند سالم است.
|
||||||
|
- endpointهای owner-only جدید (`insurance/{id}`, `doctor-address/{id}`, `appointment-settings/*`) توسط سایت عمومی مصرف نمیشوند → بدون اثر.
|
||||||
|
- بعد از تغییر: `npm run lint` و `npm run build`.
|
||||||
|
```
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# نمایش روزهای تعطیل/غیرقابلانتخاب روی تقویم صفحهی نوبت + احترام به بازهی رزرو
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — سایت عمومی نوبتدهی. **این پرامپت بعد از پرامپت backend اجرا شود.**
|
||||||
|
|
||||||
|
> **Cross-repo:** این کار به endpoint جدید backend وابسته است:
|
||||||
|
> `GET /api/v1/appointment-settings/month-availability/{doctorUuid}?year=&month=`
|
||||||
|
> که در پرامپت `clinicpro/.claude/prompt/doctor-booking-window-and-month-availability.md` ساخته میشود. اگر آن endpoint هنوز نیست، **متوقف شو و اول backend را اجرا کن.**
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
تقویم دوماههی صفحهی نوبت (`/appointment/[doctorId]`) الان همهی روزهای آینده را قابلانتخاب نشان میدهد، چون در اصلاح قبلی فراخوانی endpoint ناموجود `appointment/not-available` حذف شد و `disabledDates={[]}` پاس داده میشود. در نتیجه روزهای تعطیلِ پزشک (مثلاً ۲۷/۰۳/۱۴۰۵ که با date override بسته شده) روی تقویم خاکستری نمیشوند و کاربر میتواند رویشان کلیک کند — بعد فقط لیست اسلات خالی میبیند. همچنین بازهی رزرو (مثلاً «تا ۲ ماه جلوتر») روی تقویم اعمال نمیشود.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
تقویم باید روزهای غیرقابلانتخاب را خاکستری و disable کند. این روزها از endpoint `month-availability` میآیند: تعطیلات، date overrideهای بسته، روزهای بدون شیفت، و روزهای خارج از بازهی رزرو پزشک. چون تقویم دوماهه است، باید برای **هر دو ماه نمایشدادهشده** داده گرفته شود و با تغییر ماه (navigation) دادهی ماه جدید هم لود شود.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `services/response.js` | افزودن `getMonthAvailability(doctorUuid, year, month)` |
|
||||||
|
| `app/appointment/[doctorId]/page.js` | الان `disabledDates={[]}` میفرستد (سرور-ساید) |
|
||||||
|
| `components/appointment/index.js` → `Container` → `date/index.js` → `Time` → `SelectDatePicker` | زنجیرهی پاسدادن `disabledDates` |
|
||||||
|
| `app/component/date/datePicker/index.js` | تقویم دوماههی client؛ `disabledDates` آرایهی Unix timestamp است |
|
||||||
|
| `components/common/InlineJalaliMonth.js` | رندر یک ماه؛ `isDisabled(date)` روز را خاکستری/disable میکند |
|
||||||
|
| `lib/appointmentSlots.js` | adapter موجود اسلاتها (الگوی adapter جدا) |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
### `app/appointment/[doctorId]/page.js` — disabledDates خالی
|
||||||
|
|
||||||
|
```js
|
||||||
|
let doctor = null;
|
||||||
|
try {
|
||||||
|
const doctorRes = await axiosInstance.get(`${API_URL}/api/v1/doctor/${doctorId}`);
|
||||||
|
doctor = doctorRes.data?.data?.data;
|
||||||
|
} catch (error) {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppointmentPage doctor={doctor} disabledDates={[]} matchedCity={matchedCity} />
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `app/component/date/datePicker/index.js` — مصرف disabledDates
|
||||||
|
|
||||||
|
```js
|
||||||
|
function DatePicker({ setDate, disabledDates = [] }) {
|
||||||
|
const [baseMonth, setBaseMonth] = useState(moment().tz("Asia/Tehran"));
|
||||||
|
// ...
|
||||||
|
const isDisabled = (date) => {
|
||||||
|
const day = moment(date).startOf("day");
|
||||||
|
if (day.isBefore(moment().startOf("day"))) return true;
|
||||||
|
return disabledDates.some((ts) => day.isSame(moment.unix(ts).startOf("day")));
|
||||||
|
};
|
||||||
|
// auto-select nearest available day از همین isDisabled استفاده میکند
|
||||||
|
// دو InlineJalaliMonth: baseMonth (راست) + secondMonth = baseMonth+1 (چپ)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `disabledDates` آرایهی Unix timestamp (ثانیه) است؛ `isDisabled` گذشته را هم میبندد. `baseMonth` با فلشهای ناوبری تغییر میکند.
|
||||||
|
|
||||||
|
### `services/response.js` — توابع slot موجود
|
||||||
|
|
||||||
|
```js
|
||||||
|
getAppointmentSlots: (doctor_uuid, date) =>
|
||||||
|
api.get(`api/v1/appointment-slots?doctor_uuid=${doctor_uuid}&date=${date}`, removeTokenHead),
|
||||||
|
```
|
||||||
|
|
||||||
|
## قرارداد endpoint جدید backend (مرجع)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/appointment-settings/month-availability/{doctorUuid}?year=&month= (PUBLIC)
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"year": 2026,
|
||||||
|
"month": 6,
|
||||||
|
"disabled_dates": ["2026-06-16", "2026-06-20"],
|
||||||
|
"online_booking_enabled": true,
|
||||||
|
"booking_window": { "value": 2, "unit": "month" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `disabled_dates` فرمت `Y-m-d` میلادی. **قبل از پیادهسازی، با پرامپت backend چک کن که endpoint ورودی شمسی میخواهد یا میلادی** — اگر میلادی است، ماه شمسیِ نمایشدادهشده را باید به بازهی میلادی تبدیل و برای ماه(های) میلادی متناظر صدا بزنی. interceptor `services/api.js` یکبار پاسخ را باز میکند → داده در `res.data`.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
اجرای مرحلهبهمرحله؛ بعد از هر قابلیت `npm run build` و سپس commit جدا.
|
||||||
|
|
||||||
|
### ۱. افزودن `getMonthAvailability` به `services/response.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
getMonthAvailability: (doctor_uuid, year, month) =>
|
||||||
|
api.get(`api/v1/appointment-settings/month-availability/${doctor_uuid}`, {
|
||||||
|
params: { year, month },
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
(public — بدون `requireAuth`؛ از همان الگوی `getAppointmentSlots` پیروی کن.)
|
||||||
|
|
||||||
|
### ۲. لود دادهی دردسترسبودن در `DatePicker` و disableکردن روزها
|
||||||
|
|
||||||
|
- چون `DatePicker` کامپوننت client است و `doctor.uuid` در آن در دسترس نیست (الان فقط `setDate`/`disabledDates` میگیرد)، یا `doctorUuid` را بهعنوان prop از زنجیره پاس بده، یا از `useParams().doctorId` داخل `DatePicker` بخوان (همان uuid است — الگوی موجود `SendAppo`).
|
||||||
|
- داخل `DatePicker` یک state `disabledTimestamps` نگهدار و با `useEffect` وابسته به `baseMonth`:
|
||||||
|
- برای ماهِ `baseMonth` و ماهِ `secondMonth` (baseMonth+1) `getMonthAvailability` را صدا بزن (تبدیل ماه نمایشی به year/month مطابق قرارداد backend).
|
||||||
|
- `disabled_dates` (`Y-m-d`) را به Unix timestamp (`moment(d,"YYYY-MM-DD").startOf("day").unix()`) تبدیل و در state بریز.
|
||||||
|
- `isDisabled` فعلی را نگهدار ولی منبع `disabledDates` را از این state بگیر (یا prop `disabledDates` را با state داخلی merge کن). گذشته همچنان بسته بماند.
|
||||||
|
- **auto-select**: منطق انتخاب نزدیکترین روز قابلانتخاب باید بعد از لود `disabledTimestamps` اجرا/بازاجرا شود تا روی روز تعطیل auto-select نشود.
|
||||||
|
|
||||||
|
### ۳. هندل ناوبری ماه
|
||||||
|
|
||||||
|
- با کلیک فلشها `baseMonth` تغییر میکند → `useEffect` دوباره دادهی دو ماه جدید را میگیرد. مطمئن شو state تجمعی است (ماههای قبلی پاک نشوند یا حداقل ماههای نمایشفعلی پوشش داده شوند) و درخواست تکراری بیمورد نزن (میتوانی ماههای لودشده را cache کنی با یک `Set`/object key=`year-month`).
|
||||||
|
|
||||||
|
### ۴. (اختیاری ولی توصیهشده) نمایش وضعیت نوبتدهی خاموش
|
||||||
|
|
||||||
|
- اگر `online_booking_enabled === false` در پاسخ، بهجای تقویم یک پیام «نوبتدهی آنلاین این پزشک غیرفعال است» نشان بده و دکمهی «تایید نوبت» را disable کن. (اگر میخواهی این بخش را جدا کنی، در گزارش ذکر کن.)
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **وابستگی cross-repo:** بدون endpoint `month-availability` این کار کامل نمیشود. اگر نبود متوقف شو.
|
||||||
|
- **شمسی/میلادی:** تقویم سایت شمسی است؛ endpoint احتمالاً میلادی میخواهد. تبدیل را با `moment-jalaali` انجام بده و **حدس نزن** — قرارداد دقیق را از پرامپت/داک backend بگیر. یک ماه شمسی روی دو ماه میلادی میافتد؛ یا برای پوشش کامل، بازهی میلادیِ روزهای نمایشدادهشده را محاسبه کن.
|
||||||
|
- **timestampها Unix (ثانیه):** `disabledDates` که `isDisabled` میخواند آرایهی Unix ثانیه است؛ تبدیل `Y-m-d → unix` را با `startOf("day")` در `Asia/Tehran` انجام بده تا با منطق فعلی همخوان شود.
|
||||||
|
- **interceptor:** `request.*` بدنه را یکبار باز میکند → `res.data.disabled_dates`.
|
||||||
|
- **عدم رگرسیون:** تقویم دوماهه، ترتیب RTL (خرداد راست، تیر چپ)، فلشها، و انتخاب خودکار نباید بشکنند. `InlineJalaliMonth` فقط `isDisabled` را مصرف میکند — منطق رنگ خاکستری از قبل هست.
|
||||||
|
- **Multi-domain/Jalali/RTL:** حفظ شوند.
|
||||||
|
- **تست:** `npm run build`؛ سپس دستی با پزشک `4a0594b1-008b-478a-a593-259b95d8c2dd` که یک date override بسته روی ۲۷/۰۳/۱۴۰۵ دارد — آن روز باید خاکستری و غیرقابلکلیک باشد، و روزهای خارج از بازهی رزرو هم همینطور. سپس commit با پیام توصیفی.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# اصلاح فیلدهای «جزئیات نوبت» در داشبورد
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی، داشبورد).
|
||||||
|
|
||||||
|
> **Cross-repo:** وابسته به پرامپت بکاند `clinicpro/.claude/prompt/appointment-detail-enrich.md` که `doctor.specialties` و `address` را به پاسخ نوبت اضافه میکند. آن **اول** اجرا شود.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
در داشبورد → «نوبتهای من» → «مشاهده جزئیات»، کارت «جزئیات نوبت شما» (`DetailLg`/`DetailSm`) فیلدهایی میخواند که با شکل واقعی پاسخ نوبت نمیخوانند، پس تاریخ/ساعت/تخصص/آدرس/تلفن خالی یا غلط نمایش داده میشوند.
|
||||||
|
|
||||||
|
پاسخ واقعی نوبت (پس از پرامپت بکاند): `{ uuid, doctor:{name, specialties:[{uuid,name}]}, address:{address, telephone, map:{latitude,longitude}, ...}, slot_start, slot_end, status, ... }`. تاریخها Unixاند.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
نگاشت فیلدها در `DetailLg.js` و `DetailSm.js` به مقادیر واقعی:
|
||||||
|
|
||||||
|
| نمایش | فعلی (غلط) | درست |
|
||||||
|
|------|-----------|------|
|
||||||
|
| تاریخ نوبت | `appointmentData?.start_time` | `appointmentData?.slot_start` |
|
||||||
|
| ساعت نوبت | `appointmentData?.slot?.time` | `convertTimestampToTime(appointmentData?.slot_start)` |
|
||||||
|
| تخصص | `appointmentData?.doctor?.specialty?.name` | `appointmentData?.doctor?.specialties?.[0]?.name` |
|
||||||
|
| آدرس | `appointmentData?.address?.address` | همان (درست) |
|
||||||
|
| تلفن | `appointmentData?.address?.phone` | `appointmentData?.address?.telephone` |
|
||||||
|
| نقشه | `appointmentData?.address?.map` | همان (درست) |
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/dashboard/userAccount/sidebars/turns/isTurnsDetails/DetailLg.js` | نمای دسکتاپ جزئیات |
|
||||||
|
| `components/dashboard/userAccount/sidebars/turns/isTurnsDetails/DetailSm.js` | نمای موبایل جزئیات |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی — هر دو فایل مشابه)
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{convertTimestampToJalali(appointmentData?.start_time)} // ❌ start_time
|
||||||
|
ساعت {appointmentData?.slot?.time} // ❌ slot.time
|
||||||
|
تخصص: {appointmentData?.doctor?.specialty?.name} // ❌ specialty.name
|
||||||
|
{appointmentData?.address?.phone} // ❌ phone
|
||||||
|
```
|
||||||
|
`convertTimestampToTime` از قبل در `helper` هست و در `DetailLg` import شده.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. اصلاح `DetailLg.js`
|
||||||
|
- تاریخ: `convertTimestampToJalali(appointmentData?.slot_start)`
|
||||||
|
- ساعت: `convertTimestampToTime(appointmentData?.slot_start)` (بهجای `appointmentData?.slot?.time`)
|
||||||
|
- تخصص: `appointmentData?.doctor?.specialties?.[0]?.name`
|
||||||
|
- تلفن: `appointmentData?.address?.telephone`
|
||||||
|
- آدرس و `map` (مسیریابی) بدون تغییر (درستاند).
|
||||||
|
|
||||||
|
### ۲. اصلاح `DetailSm.js`
|
||||||
|
همان نگاشتها را اعمال کن (این فایل `convertTimestampToTime` را import نکرده باشد، اضافه کن). ساعت از `slot_start`.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- اگر تخصص خالی بود (`specialties` خالی) یا آدرس `null` بود (پزشک بدون آدرس)، بخش مربوط نباید crash کند — با optional chaining (`?.`) و عدم نمایش خطی که داده ندارد، تحمل کن.
|
||||||
|
- `slot_start` Unix ثانیه است؛ `convertTimestampToJalali`/`convertTimestampToTime` همان را میگیرند (مثل `List.js`).
|
||||||
|
- `address.map.latitude/longitude` رشتهاند (از `DoctorAddress::toArray`) — برای لینک مسیریابی کافیاند.
|
||||||
|
- App Router، RTL، فونت Vazir؛ ساختار/استایل کارت حفظ شود، فقط منبع داده اصلاح شود.
|
||||||
|
- بعد از تغییر: `npm run build` بدون خطا؛ دستی — یک نوبت را باز کن، تاریخ/ساعت/پزشک/تخصص/آدرس/تلفن درست نمایش داده شوند.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# اجباریکردن فیلدهای اطلاعات حساب کاربری در صفحه نوبتگیری
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی).
|
||||||
|
|
||||||
|
> کاملاً frontend است. هیچ تغییری در بکاند لازم نیست — فقط اعتبارسنجی سمت کلاینت قبل از ادامه به مرحلهی پرداخت.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
در صفحهی نوبتگیری (`/appointment/[doctorId]`)، بخش «اطلاعات حساب کاربری» (و در حالت «شخص دیگر»، «اطلاعات کاربر جدید») قبل از رفتن به مرحلهی پرداخت هیچ اعتبارسنجی اجباری ندارد؛ `SubmitData.handleSubmit` فقط `setErrors({})` میکند و مستقیم به API میفرستد، و خطاها فقط از پاسخ سرور برمیگردند. در نتیجه کاربر میتواند بدون نام/کد ملی/جنسیت/بیمه نوبت بگیرد و رکورد ناقص ثبت شود (همان مشکلی که باعث شد نام کاربر خالی بماند).
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
این پنج فیلد در **هر دو حالت** (نوبت برای خود کاربر و برای شخص دیگر) **اجباری** شوند و تا تکمیل نشدن، فرم به مرحلهی بعد نرود و خطای فیلدی نمایش دهد:
|
||||||
|
- کد ملی (`national_code`) — ۱۰ رقم
|
||||||
|
- نام (`name`)
|
||||||
|
- نام خانوادگی (`family`)
|
||||||
|
- جنسیت (`gender`)
|
||||||
|
- نوع بیمه (`basic_insurance`)
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/appointment/detail/SubmitData.js` | `handleSubmit` — جای افزودن اعتبارسنجی اجباری قبل از API |
|
||||||
|
| `components/appointment/detail/Form.js` | فیلدها از قبل `error={errors?.<field>}` میگیرند (نیازی به تغییر ساختار نیست) |
|
||||||
|
| `components/appointment/detail/index.js` | `errors` state و `emptyPatientData()` (شکل فیلدها) |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
`SubmitData.handleSubmit` — بدون اعتبارسنجی اجباری:
|
||||||
|
```js
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const isChanged = JSON.stringify(prevData) !== JSON.stringify(data);
|
||||||
|
setErrors({});
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (!isForAnother && isChanged) { /* PATCH/POST profile */ }
|
||||||
|
if (doctor && selectedSlot) { /* postAppointment */ }
|
||||||
|
...
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.response?.status === 409) { setStep(0); ... }
|
||||||
|
setErrors(error.response.data); // فقط خطای سرور
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
شکل دادهی هر فیلد (از `detail/index.js`):
|
||||||
|
```js
|
||||||
|
// data.<field> = { value: <...>, isEdit: <bool> }
|
||||||
|
// gender.value: رشته "male"/"female" (یا "" )
|
||||||
|
// basic_insurance.value: آبجکت { id } یا "" (در Form با findBasicInsuranceVal مپ میشود)
|
||||||
|
// national_code/name/family.value: رشته
|
||||||
|
```
|
||||||
|
|
||||||
|
`Form.js` از قبل `error` را به هر فیلد پاس میدهد:
|
||||||
|
```jsx
|
||||||
|
<EditField ... name="national_code" error={errors?.national_code} />
|
||||||
|
<EditField ... name="name" error={errors?.name} />
|
||||||
|
<EditField ... name="family" error={errors?.family} />
|
||||||
|
<DefaultSelect ... name="gender" error={errors?.gender} />
|
||||||
|
<DefaultSelect ... name="basic_insurance" error={errors?.basic_insurance} />
|
||||||
|
```
|
||||||
|
> یعنی فقط کافی است `SubmitData` یک آبجکت `errors` با کلیدهای همین فیلدها بسازد و `setErrors` کند؛ نمایش خطا خودکار کار میکند.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. تابع اعتبارسنجی اجباری در `SubmitData`
|
||||||
|
|
||||||
|
یک `validate(data)` بساز که آبجکت خطا (کلید فیلد → پیام فارسی) برگرداند. در ابتدای `handleSubmit`، قبل از `setLoading(true)` و قبل از هر فراخوانی API، اجرا شود؛ اگر خطا داشت `setErrors(...)` و `return` (توقف).
|
||||||
|
|
||||||
|
```js
|
||||||
|
const validate = () => {
|
||||||
|
const errs = {};
|
||||||
|
const nationalCode = (data?.national_code?.value || "").trim();
|
||||||
|
if (!data?.name?.value?.trim()) errs.name = "نام الزامی است";
|
||||||
|
if (!data?.family?.value?.trim()) errs.family = "نام خانوادگی الزامی است";
|
||||||
|
if (!nationalCode) errs.national_code = "کد ملی الزامی است";
|
||||||
|
else if (!/^\d{10}$/.test(nationalCode)) errs.national_code = "کد ملی باید ۱۰ رقم باشد";
|
||||||
|
// gender.value رشته است
|
||||||
|
if (!data?.gender?.value) errs.gender = "جنسیت الزامی است";
|
||||||
|
// basic_insurance.value آبجکت {id} یا "" است
|
||||||
|
if (!data?.basic_insurance?.value?.id) errs.basic_insurance = "نوع بیمه الزامی است";
|
||||||
|
return errs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const errs = validate();
|
||||||
|
if (Object.keys(errs).length > 0) {
|
||||||
|
setErrors(errs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrors({});
|
||||||
|
setLoading(true);
|
||||||
|
// ... بقیه بدون تغییر
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- این اعتبارسنجی در **هر دو حالت** اجرا شود (هم `isForAnother=false` هم `true`) — یعنی خارج از شرط `if (!isForAnother && isChanged)` و قبل از آن.
|
||||||
|
- `DefaultSelect` (جنسیت/بیمه) باید پیام خطا را نمایش دهد؛ تأیید کن `error` prop در `DefaultSelect` به helperText/حالت خطای MUI وصل است (اگر نیست، اضافه کن — ولی `EditField` این را دارد).
|
||||||
|
|
||||||
|
### ۲. هماهنگی شکل داده
|
||||||
|
|
||||||
|
- `gender.value` رشتهی `"male"`/`"female"` است (از `buildProfileData`/`DefaultSelect`)، پس بررسی `!data.gender.value` درست است.
|
||||||
|
- `basic_insurance.value` یا آبجکت `{ id }` است (از پروفایل) یا کل آیتم بیمه (از انتخاب کاربر در `DefaultSelect`) — هر دو `id` دارند، پس `?.value?.id` درست کار میکند.
|
||||||
|
- کد ملی ممکن است از پروفایلِ تأییدشده `isEdit:false` (قفل) ولی پر باشد — این حالت معتبر است (مقدار دارد، خطا نمیدهد).
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **بکاند تغییر نمیکند**؛ این صرفاً gate سمت کلاینت است. خطاهای سرور (`setErrors(error.response.data)`) در catch دستنخورده بماند.
|
||||||
|
- اعتبارسنجی باید **قبل از** ساخت/ارسال نوبت و پروفایل اجرا شود تا رکورد ناقص ثبت نشود.
|
||||||
|
- پیامها فارسی، فیلدها همان کلیدهایی که `Form.js` انتظار دارد (`name`, `family`, `national_code`, `gender`, `basic_insurance`).
|
||||||
|
- در حالت «شخص دیگر»، فیلد `phone` هم در فرم هست؛ اگر میخواهی آن را هم اجباری کنی به validate اضافه کن — ولی طبق درخواست فقط این پنج فیلد لازماند.
|
||||||
|
- App Router، RTL، MUI v5؛ از `EditField`/`DefaultSelect` موجود استفاده کن، کامپوننت جدید نساز.
|
||||||
|
- بعد از تغییر: `npm run build` بدون خطا؛ دستی تست کن — با فیلدهای خالی دکمهی ادامه نباید جلو برود و باید زیر هر فیلد خطا نشان دهد؛ با تکمیل همه، باید به مرحلهی پرداخت برود.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# انتخاب تاریخ تولد بهصورت مرحلهای: سال ← ماه ← روز
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی، داشبورد کاربر).
|
||||||
|
|
||||||
|
> کاملاً frontend است. هیچ تغییری در بکاند یا قرارداد API لازم نیست؛ خروجی همان رشتهی جلالی فعلی است.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
در داشبورد (`/dashboard` → اطلاعات کاربری)، فیلد «تاریخ تولد» از `components/common/JalaliDatePicker` استفاده میکند که فقط یک نمای **ماهانه** دارد: هدر فقط «ماه سال» را نشان میدهد و با فلشها فقط ماهبهماه جلو/عقب میرود. برای انتخاب سال تولد (مثلاً ۱۳۷۰) کاربر باید دهها بار فلش بزند — تجربهی بدی برای تاریخ تولد است.
|
||||||
|
|
||||||
|
خواسته: تقویمِ تاریخ تولد باید مرحلهای باشد — **اول سال، بعد ماه، بعد روز**.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
افزودن یک حالتِ انتخابِ مرحلهای (year → month → day) به `JalaliDatePicker` بهصورت **opt-in با prop**، و فعالکردن آن فقط برای فیلد تاریخ تولد. سایر استفادهها (انتخاب تاریخ نوبت و پنل) باید **دستنخورده** بمانند.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/common/JalaliDatePicker.js` | date picker جلالیِ مشترک — افزودن حالت مرحلهای |
|
||||||
|
| `components/dashboard/userAccount/detailUser/information/form/DateBirthday.js` | فیلد تاریخ تولد — فعالکردن حالت جدید با prop |
|
||||||
|
|
||||||
|
> **هشدار سازگاری:** `JalaliDatePicker` در این فایلها هم استفاده میشود و نباید رفتارشان تغییر کند:
|
||||||
|
> `app/component/DatePickerContent.js`, `app/component/date/datePicker/date/FirstDatePicker.js`, `.../SecondDatePicker.js`, `components/layoutPanel/userDetail/Date.js`. پس حالت جدید باید پیشفرض **خاموش** باشد.
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
`JalaliDatePicker.js` — فقط نمای ماهانه. هدر و ناوبری:
|
||||||
|
```jsx
|
||||||
|
const handlePrevMonth = () => setDisplayDate(moment(displayDate).subtract(1, "jMonth"));
|
||||||
|
const handleNextMonth = () => setDisplayDate(moment(displayDate).add(1, "jMonth"));
|
||||||
|
// ...
|
||||||
|
// هدر: فقط ماه و سال + دو فلش ماه
|
||||||
|
<Box sx={{ fontWeight: 600, fontSize: "14px" }}>
|
||||||
|
{MONTHS[displayDate.jMonth()]} {displayDate.jYear()}
|
||||||
|
</Box>
|
||||||
|
// ... سپس گرید روزها
|
||||||
|
```
|
||||||
|
`MONTHS` (۱۲ ماه جلالی) از قبل در همین فایل تعریف شده. `moment` = `moment-jalaali`؛ متدها: `jYear()`, `jMonth()`, `jDate()`, `moment.jDaysInMonth(y, m)`.
|
||||||
|
|
||||||
|
`DateBirthday.js`:
|
||||||
|
```jsx
|
||||||
|
<JalaliDatePicker
|
||||||
|
value={data}
|
||||||
|
placeholder="تاریخ تولد"
|
||||||
|
error={error}
|
||||||
|
onChange={(e) => changeData(convertToJalali(e._d), "birthday")}
|
||||||
|
textFieldProps={{ /* CalendarEdit icon */ }}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. افزودن حالت مرحلهای به `JalaliDatePicker`
|
||||||
|
|
||||||
|
- یک prop جدید بگیر: `stepwise = false` (پیشفرض خاموش تا سازگاری حفظ شود).
|
||||||
|
- یک state داخلی `viewMode` با مقادیر `"year" | "month" | "day"`. وقتی Popover با `stepwise=true` باز میشود، `viewMode` روی `"year"` شروع شود؛ در غیر این صورت رفتار فعلی (مستقیم نمای روز) حفظ شود.
|
||||||
|
- **نمای سال:** یک گرید از سالها (مثلاً ۱۲ سال در هر صفحه، با دو فلش برای صفحهی قبل/بعدِ بازهی سالها). بازهی منطقی برای تولد: از `currentJalaliYear - 100` تا `currentJalaliYear`. انتخاب سال → `displayDate` با آن سال بهروز شود و `viewMode = "month"`.
|
||||||
|
- **نمای ماه:** گرید ۱۲ ماه از `MONTHS`. انتخاب ماه → `displayDate` با آن ماه، و `viewMode = "day"`.
|
||||||
|
- **نمای روز:** همان گرید روزهای فعلی (بدون تغییر). انتخاب روز → `onChange` صدا زده شود و Popover بسته شود (مثل حالا).
|
||||||
|
- در هدرِ حالت مرحلهای، عنوان قابلکلیک باشد تا کاربر بتواند به مرحلهی بالاتر برگردد: در نمای ماه، کلیک روی سال → برگشت به نمای سال؛ در نمای روز، کلیک روی «ماه سال» → برگشت به نمای ماه. (ناوبری رو به عقب)
|
||||||
|
- وقتی Popover بسته شد، `viewMode` برای بار بعد دوباره `"year"` شود.
|
||||||
|
|
||||||
|
طرح کلی:
|
||||||
|
```jsx
|
||||||
|
function JalaliDatePicker({ /* ...props */, stepwise = false }) {
|
||||||
|
const [viewMode, setViewMode] = useState(stepwise ? "year" : "day");
|
||||||
|
// باز شدن popover (stepwise): setViewMode("year")
|
||||||
|
// بسته شدن: setViewMode(stepwise ? "year" : "day")
|
||||||
|
|
||||||
|
// نمای سال
|
||||||
|
const renderYears = () => { /* گرید سالها currentYear-100..currentYear */ };
|
||||||
|
// نمای ماه
|
||||||
|
const renderMonths = () => { /* گرید MONTHS، onClick → setDisplayDate(jMonth) + setViewMode("day") */ };
|
||||||
|
// نمای روز = همان رندر فعلی
|
||||||
|
|
||||||
|
// در بدنهی Popover:
|
||||||
|
// stepwise ? (viewMode==="year" ? renderYears() : viewMode==="month" ? renderMonths() : renderDays())
|
||||||
|
// : renderDays()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- منطق `value`/`selectedDate`/`onChange` و خروجی (همان `moment` object که `DateBirthday` با `e._d` و `convertToJalali` مصرف میکند) **بدون تغییر** بماند.
|
||||||
|
|
||||||
|
### ۲. فعالکردن در `DateBirthday.js`
|
||||||
|
فقط prop اضافه کن:
|
||||||
|
```jsx
|
||||||
|
<JalaliDatePicker
|
||||||
|
value={data}
|
||||||
|
placeholder="تاریخ تولد"
|
||||||
|
error={error}
|
||||||
|
stepwise
|
||||||
|
onChange={(e) => changeData(convertToJalali(e._d), "birthday")}
|
||||||
|
textFieldProps={{ /* بدون تغییر */ }}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **سازگاری عقبرو:** `stepwise` پیشفرض `false`؛ هیچیک از ۴ مصرفکنندهی دیگر (تاریخ نوبت/پنل) نباید رفتارشان عوض شود. این را با grep تأیید کن و دست به آن فایلها نزن.
|
||||||
|
- خروجی `onChange` دقیقاً همان شیء `moment` فعلی باشد تا `convertToJalali(e._d)` در `DateBirthday` و مصرف بکاند (`birthday`/`date_of_birth`) نشکند.
|
||||||
|
- بازهی سالها برای تولد: `[امسالِ جلالی - 100, امسالِ جلالی]`، نزولی (سالهای جدیدتر بالا) یا صعودی — هرکدام UX بهتری دارد؛ پیشفرض نزولی منطقیتر است.
|
||||||
|
- RTL، فونت Vazir، MUI v5 (`Box`/`Popover`/`IconButton` که از قبل import شدهاند)؛ کتابخانهی جدید اضافه نکن. از `MONTHS` و `moment-jalaali` موجود در همان فایل استفاده کن.
|
||||||
|
- اعداد فارسی/لاتین: مطابق رفتار فعلی فایل (`usePersianDigits: false`) نگهدار تا یکدست بماند.
|
||||||
|
- بعد از تغییر: `npm run build` بدون خطا؛ دستی تست کن — در داشبورد فیلد تاریخ تولد: کلیک → ابتدا گرید سالها، انتخاب سال → ماهها، انتخاب ماه → روزها، انتخاب روز → مقدار در فیلد ست شود؛ و یک فیلد تاریخِ نوبت را هم چک کن که هنوز مثل قبل (مستقیم نمای روز) کار میکند.
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
# صفحهٔ بلاگ: بردکرامب دستهبندی، فیلتر دستهبندی، و اصلاح ستون «مطالب مرتبط»
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی)
|
||||||
|
|
||||||
|
پرامپت همتا در backend: `clinicpro/.claude/prompt/blog-tag-filter-and-facets.md` —
|
||||||
|
**آن پرامپت باید اول اجرا و تأیید شود.** این پرامپت به دو چیز از آن وابسته است:
|
||||||
|
۱) `GET /api/v1/blogs?tag=<name>` که الان همیشه لیست خالی میدهد، ۲) endpoint جدید
|
||||||
|
`GET /api/v1/blogs/tags` برای واژگان چیپها.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
صفحهٔ `https://yasuj-nobat.ir/blogs` و صفحهٔ جزئیات مقاله چند ایراد همزمان دارند: فیلتر
|
||||||
|
دستهبندی هیچوقت نتیجه نمیدهد، دستهبندی در بردکرامب لینک ندارد (در حالی که خودش یک صفحهٔ
|
||||||
|
مقصد دارد/باید داشته باشد)، ستون «مطالب مرتبط» عرض و ارتفاع صفحه را میبلعد و اصلاً «مرتبط»
|
||||||
|
نیست، و سلسلهمراتب heading در هر دو صفحه شکسته است.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
### ۱) بردکرامب دستهبندی لینک ندارد
|
||||||
|
|
||||||
|
در `components/blog/head/index.js` بردکرامب این شکل رندر میشود:
|
||||||
|
|
||||||
|
```
|
||||||
|
بلاگ > چشم و گوش > علل و اهمیت بررسی تخصصی وقتی هالههای رنگی اطراف نورها را میبینید
|
||||||
|
↑ لینک ↑ <p> ساده، بدون لینک
|
||||||
|
```
|
||||||
|
|
||||||
|
«چشم و گوش» یک دستهبندی واقعی است (۱۶ مقاله دارد) و باید به صفحهٔ خودش برود.
|
||||||
|
JSON-LD `BreadcrumbList` در `app/blog/[slug]/page.js` هم فقط سه سطح دارد و دسته را ندارد.
|
||||||
|
|
||||||
|
### ۲) فیلتر دستهبندی در `/blogs` کار نمیکند
|
||||||
|
|
||||||
|
دو علت مستقل:
|
||||||
|
|
||||||
|
- **backend** (در پرامپت همتا رفع میشود): `?tag=<فارسی>` همیشه `totalRecords: 0` میدهد.
|
||||||
|
- **frontend**: منبع چیپها ناقص است و انتخاب در URL ثبت نمیشود، پس لینکپذیر/قابلاشتراک
|
||||||
|
نیست، refresh آن را از دست میدهد، و بردکرامب هم جایی برای اشاره کردن ندارد.
|
||||||
|
|
||||||
|
`components/blogs/title/index.js` چیپها را از `getBlogs({page:1, limit:50})` میسازد؛ سقف
|
||||||
|
`limit` در backend ۵۰ است و ۱۱۷ مقالهٔ منتشرشده وجود دارد → تگهای صفحات بعد هرگز چیپ نمیشوند.
|
||||||
|
`city_id` هم پاس داده نمیشود، پس روی دامنهٔ شهری ممکن است چیپی نمایش داده شود که هیچ پستی روی
|
||||||
|
آن دامنه ندارد و کلیک روی آن «مقالهای یافت نشد» میدهد.
|
||||||
|
|
||||||
|
### ۳) ستون «مطالب مرتبط» بزرگ است — و مرتبط نیست
|
||||||
|
|
||||||
|
- `components/blog/relatedContent/index.js` با `lg:w-fit` عرض ندارد؛ عرض ستون را **عنوانِ
|
||||||
|
بلندترین مقاله** تعیین میکند، چون `Item.js` عنوان را با `lg:text-nowrap` رندر میکند.
|
||||||
|
- `Item.js` بین عنوان و تاریخ `gap-[24px]` دارد و هر ردیف `py-[20px]` → کارت خیلی بلند میشود.
|
||||||
|
- محتوای این ستون اصلاً مرتبط نیست: `app/blog/[slug]/page.js:132` صرفاً ۶ مقالهٔ آخر را
|
||||||
|
میگیرد و ۴ تای اول (بهجز خود مقاله) را نشان میدهد.
|
||||||
|
|
||||||
|
### ۴) سلسلهمراتب heading شکسته است («فونت و ...»)
|
||||||
|
|
||||||
|
- `components/blogs/latestArticles/Article.js:27` — عنوان **هر کارت** `<h1>` است؛ صفحهٔ
|
||||||
|
`/blogs` با ۱۲ کارت، ۱۲ تا `<h1>` دارد و هیچ `<h1>` واقعی برای خود صفحه ندارد.
|
||||||
|
- `components/blog/relatedContent/Item.js:25` — عنوان هر مقالهٔ سایدبار `<h2>` است و با
|
||||||
|
`<h2>`های خودِ متن مقاله (`.blog-content h2` در `app/globals.css:920`، ۲۰px) همرده میشود.
|
||||||
|
- `components/blog/head/index.js:36` — `<h1>` مقاله در دسکتاپ `24px` است و `.blog-content h2`
|
||||||
|
`20px`؛ اختلاف ۴px یعنی عنوان اصلی عملاً از تیترهای داخلی متمایز نیست.
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
|
||||||
|
- ✅ موفق: در صفحهٔ مقاله، کلیک روی «چشم و گوش» در بردکرامب → `/blogs?tag=چشم و گوش`؛ صفحه با
|
||||||
|
همان چیپ فعال باز میشود و فقط مقالات آن دسته را نشان میدهد.
|
||||||
|
- ✅ موفق: کلیک روی هر چیپ در `/blogs` → آدرس مرورگر به `?tag=<name>` تغییر میکند (بدون reload
|
||||||
|
کامل)، لیست فیلتر میشود و `page` به ۱ برمیگردد.
|
||||||
|
- ✅ موفق: refresh روی `/blogs?tag=زنان و بارداری` همان لیست فیلترشده و همان چیپ فعال را
|
||||||
|
بازتولید میکند.
|
||||||
|
- ✅ موفق: چیپها از `GET /api/v1/blogs/tags?city_id=<id>` میآیند و روی دامنهٔ شهری فقط تگهایی
|
||||||
|
را نشان میدهند که روی همان دامنه پست دارند.
|
||||||
|
- ✅ موفق: JSON-LD `BreadcrumbList` صفحهٔ مقاله ۴ سطح دارد (خانه → مقالات → دستهٔ مقاله → عنوان)
|
||||||
|
و در [Rich Results Test](https://search.google.com/test/rich-results) بدون خطا اعتبارسنجی
|
||||||
|
میشود؛ هر `ListItem` فیلد `item` دارد.
|
||||||
|
- ✅ موفق: در `/blogs` دقیقاً **یک** `<h1>` وجود دارد؛ در صفحهٔ مقاله هم دقیقاً یک `<h1>` و
|
||||||
|
عناوین سایدبار در سطح پایینتر از `<h2>`های متناند.
|
||||||
|
- ✅ موفق: ستون «مطالب مرتبط» در دسکتاپ عرض ثابت دارد و با تغییر طول عنوانها جابهجا نمیشود؛
|
||||||
|
عنوانها حداکثر دو خط (`line-clamp-2`).
|
||||||
|
- ✅ موفق: «مطالب مرتبط» مقالاتی از **همان دستهٔ** مقالهٔ جاری است.
|
||||||
|
- ❌ خطا: `/blogs?tag=یکچیزناموجود` → بدون کرش، پیام موجودِ «مقالهای یافت نشد»، و چیپ «همه»
|
||||||
|
فعال میماند (چون چیپ متناظری وجود ندارد).
|
||||||
|
- ❌ خطا: اگر `GET /api/v1/blogs/tags` خطا داد، ردیف چیپها رندر نشود ولی لیست مقالات و بقیهٔ
|
||||||
|
صفحه سالم بماند (مثل رفتار فعلی `catch` که `setTags([])` میکند).
|
||||||
|
- ⚠️ مرزی: مقالهای که تگ ندارد → بردکرامب سهسطحی میماند (بدون `>` اضافه یا آیتم خالی) و
|
||||||
|
JSON-LD هم سهسطحی و معتبر بماند.
|
||||||
|
- ⚠️ مرزی: تگ فارسی با «،» و فاصله (`تنفس، آلرژی و عفونت`) در URL درست encode/decode شود و
|
||||||
|
رفتوبرگشت بردکرامب ← چیپ فعال را بشکند نه.
|
||||||
|
- ⚠️ مرزی: وقتی «مطالب مرتبطِ همدسته» کمتر از ۴ مورد باشد (مثلاً `قلب و عروق` که ۱ پست دارد)،
|
||||||
|
باقی از آخرین مقالات پر شود و هیچوقت خودِ مقالهٔ جاری تکرار نشود.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `app/blogs/page.js` | صفحهٔ لیست (server) — `getStateInfo`، پاس دادن `cityId` |
|
||||||
|
| `components/blogs/index.js` | کلاینت لیست — state صفحه/تگ، fetch |
|
||||||
|
| `components/blogs/title/index.js` | منبع و رندر چیپهای دستهبندی |
|
||||||
|
| `components/blogs/title/ItemTitle.js` | چیپ تکی (MUI Button) |
|
||||||
|
| `components/blogs/latestArticles/Article.js` | کارت مقاله (`<h1>` اشتباه) |
|
||||||
|
| `components/blog/head/index.js` | بردکرامب + `<h1>` + متادیتای مقاله |
|
||||||
|
| `components/blog/relatedContent/index.js` | ظرف ستون «مطالب مرتبط» |
|
||||||
|
| `components/blog/relatedContent/Item.js` | ردیف مقالهٔ مرتبط |
|
||||||
|
| `components/blog/index.js` | چیدمان دو ستونیِ صفحهٔ مقاله |
|
||||||
|
| `app/blog/[slug]/page.js` | fetch مقالات مرتبط + JSON-LD بردکرامب |
|
||||||
|
| `services/response.js` | `request.getBlogs` — محل افزودن `getBlogTagFacets` |
|
||||||
|
| `app/globals.css` | `.blog-content` (خط ۹۱۱ به بعد) |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
`components/blog/head/index.js:12-34` — بردکرامب، دسته بدون لینک:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="flex items-center justify-start gap-1">
|
||||||
|
<Link
|
||||||
|
href="/blogs"
|
||||||
|
className="text-[#9B9B9B] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal hover:text-[#525252]"
|
||||||
|
>
|
||||||
|
بلاگ
|
||||||
|
</Link>
|
||||||
|
<span className="text-[#9B9B9B] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal">
|
||||||
|
{">"}
|
||||||
|
</span>
|
||||||
|
{firstTag && (
|
||||||
|
<>
|
||||||
|
<p className="text-[#9B9B9B] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal">
|
||||||
|
{firstTag.name} {">"}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="text-[#525252] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal">
|
||||||
|
{data?.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
`components/blogs/index.js:10-58` — state تگ فقط در حافظه، بدون URL:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
function BlogsPage({ cityId = null }) {
|
||||||
|
const [blogs, setBlogs] = useState([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [selectedTag, setSelectedTag] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchBlogs = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const params = { page: page, limit: 12 };
|
||||||
|
if (selectedTag) {
|
||||||
|
params.tag = selectedTag;
|
||||||
|
}
|
||||||
|
// backend با city_id پستهای آن شهر «و» پستهای سراسری را برمیگرداند
|
||||||
|
if (cityId) {
|
||||||
|
params.city_id = cityId;
|
||||||
|
}
|
||||||
|
const response = await request.getBlogs(params);
|
||||||
|
setBlogs((response?.data || []).map(normalizeBlog));
|
||||||
|
setTotalPages(response?.meta?.totalPages || 1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching blogs:", error);
|
||||||
|
setBlogs([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchBlogs();
|
||||||
|
}, [page, selectedTag, cityId]);
|
||||||
|
```
|
||||||
|
|
||||||
|
`components/blogs/title/index.js:10-35` — منبع ناقص چیپها:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTags = async () => {
|
||||||
|
try {
|
||||||
|
// Blog tags are stored as names on each blog; build the chip list from
|
||||||
|
// the tag names actually present in published blogs.
|
||||||
|
const response = await request.getBlogs({ page: 1, limit: 50 });
|
||||||
|
const names = (response?.data || []).flatMap((b) =>
|
||||||
|
Array.isArray(b.tags) ? b.tags : []
|
||||||
|
);
|
||||||
|
const uniqueNames = [...new Set(names)];
|
||||||
|
setTags(uniqueNames.map((name) => ({ name })));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching tags:", error);
|
||||||
|
setTags([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTags();
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
`app/blog/[slug]/page.js:127-138` — «مرتبط» = صرفاً آخرینها:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const { matchedCity, isRoot } = await getStateInfo();
|
||||||
|
const scopeCityId = domainScopeCityId({ matchedCity, isRoot });
|
||||||
|
let relatedBlogs = [];
|
||||||
|
const relatedResponse = await fetchReq(
|
||||||
|
`${API_URL}/api/v1/blogs?page=1&limit=6${scopeCityId != null ? `&city_id=${scopeCityId}` : ""}`
|
||||||
|
);
|
||||||
|
relatedBlogs = (relatedResponse?.data || [])
|
||||||
|
.filter((b) => b.uuid !== blog?.uuid)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(normalizeBlog);
|
||||||
|
```
|
||||||
|
|
||||||
|
`app/blog/[slug]/page.js:184-196` — بردکرامب JSON-LD سهسطحی:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const breadcrumbJsonLd = blog
|
||||||
|
? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
itemListElement: [
|
||||||
|
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
|
||||||
|
{ "@type": "ListItem", position: 2, name: "مقالات", item: `${origin}/blogs` },
|
||||||
|
// آیتم آخر بدون `item` کل BreadcrumbList را نامعتبر میکند
|
||||||
|
{ "@type": "ListItem", position: 3, name: blog.title,
|
||||||
|
item: `${origin}/blog/${slug}` },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
```
|
||||||
|
|
||||||
|
`components/blog/relatedContent/index.js:8-19` و `Item.js:12-35` — عرض/ارتفاع بیمهار:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="p-[12px] w-full lg:w-fit md:p-[14px] lg:p-[16px] bg-[#FFF] rounded-[8px] shadow-[...]">
|
||||||
|
<AnimationTextHead text="مطالب مرتبط"><UnderlineLG /></AnimationTextHead>
|
||||||
|
<ul className="flex w-full lg:w-fit flex-col justify-start items-start">
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<li className="flex relative items-center justify-start gap-[8px] py-[12px] sm:py-[15px] md:py-[18px] lg:py-[20px]">
|
||||||
|
...
|
||||||
|
<div className="flex flex-col gap-[16px] sm:gap-[18px] md:gap-[21px] lg:gap-[24px] items-start justify-between">
|
||||||
|
<h2 className="text-[#3B3B3B] text-[14px] md:text-[16px] font-bold text-wrap lg:text-nowrap">
|
||||||
|
```
|
||||||
|
|
||||||
|
`components/blogs/latestArticles/Article.js:27` — `<h1>` در کارت:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<h1 className="text-[#3B3B3B] mb-[12px] md:mb-[14px] lg:mb-[16px] text-[16px] font-bold line-clamp-2">
|
||||||
|
{data.title}
|
||||||
|
</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. افزودن `getBlogTagFacets` به `services/response.js`
|
||||||
|
|
||||||
|
کنار `getBlogs` (خط ۱۲۷) و با همان الگوی `Authorization: ""`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
getBlogTagFacets: (params) =>
|
||||||
|
api.get("api/v1/blogs/tags", {
|
||||||
|
params,
|
||||||
|
headers: {
|
||||||
|
Authorization: "",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
پاسخ backend: `{ success: true, data: [{ name, count }, …] }` (بدون double-nesting).
|
||||||
|
interceptor در `services/api.js:78` خودِ `response.data` را برمیگرداند، پس در کامپوننت
|
||||||
|
`response?.data` همان آرایه است.
|
||||||
|
|
||||||
|
`getBlogTags` موجود (که `api/v1/tags` را صدا میزند) **حذف نشود** — واژگان دیگری است و ممکن
|
||||||
|
است جای دیگری مصرف شود؛ فقط برای چیپهای بلاگ استفاده نشود.
|
||||||
|
|
||||||
|
**نحوه تست:** در کنسول مرورگر روی `/blogs`:
|
||||||
|
`await (await import('/services/response.js')).request.getBlogTagFacets({city_id: 132})`
|
||||||
|
یا سادهتر، بعد از وظیفهٔ ۳ در Network tab ببین `api/v1/blogs/tags?city_id=…` زده میشود و
|
||||||
|
`200` با آرایهٔ ناخالی برمیگردد.
|
||||||
|
|
||||||
|
### ۲. تگ انتخابی را به URL منتقل کن (`?tag=`)
|
||||||
|
|
||||||
|
**دلیل انتخاب این راهحل:** بردکرامبِ صفحهٔ مقاله باید به «صفحهٔ دسته» لینک بدهد. ساختن یک روت
|
||||||
|
جدید `app/blogs/[tag]/page.js` هم ممکن بود، ولی همان لیست را با همان کامپوننتها تکرار میکرد،
|
||||||
|
دو canonical برای یک محتوا میساخت و به `generateStaticParams` روی واژگان آزادِ فارسی نیاز
|
||||||
|
داشت. استفاده از query param روی همان روت، صفحهٔ مقصد را میدهد بدون تکرار روت و بدون ریسک
|
||||||
|
duplicate content — همان الگوی فیلترهای موجود سایت.
|
||||||
|
|
||||||
|
**منبع حقیقت = URL**، نه state داخلی. در `components/blogs/index.js`:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
// …
|
||||||
|
|
||||||
|
function BlogsPage({ cityId = null }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
// تگ انتخابی از URL خوانده میشود تا لینک بردکرامب، refresh و اشتراکگذاری همگی
|
||||||
|
// یک حالت را بازتولید کنند؛ state داخلی دومین منبعِ حقیقت میشد.
|
||||||
|
const selectedTag = searchParams.get("tag") || null;
|
||||||
|
|
||||||
|
const [blogs, setBlogs] = useState([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
// …
|
||||||
|
|
||||||
|
const handleTagChange = (tagName) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (tagName) {
|
||||||
|
params.set("tag", tagName);
|
||||||
|
} else {
|
||||||
|
params.delete("tag");
|
||||||
|
}
|
||||||
|
setPage(1);
|
||||||
|
router.replace(params.toString() ? `/blogs?${params}` : "/blogs", {
|
||||||
|
scroll: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`useEffect` فعلی بدون تغییر میماند (وابستگی `selectedTag` هنوز درست است، فقط منبعش عوض شده).
|
||||||
|
`selectedTag` باید به `<Title>` هم پاس داده شود تا چیپ فعال از URL بیاید:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Title selectedTag={selectedTag} onTagChange={handleTagChange} cityId={cityId} />
|
||||||
|
```
|
||||||
|
|
||||||
|
چون `useSearchParams` در client component استفاده میشود، در `app/blogs/page.js` باید داخل
|
||||||
|
`<Suspense>` قرار بگیرد (الزام Next 15):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { Suspense } from "react";
|
||||||
|
// …
|
||||||
|
<Layout name="/blogs">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<BlogsPage cityId={isRoot ? null : (matchedCity?.id ?? null)} />
|
||||||
|
</Suspense>
|
||||||
|
</Layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
**نحوه تست:**
|
||||||
|
```bash
|
||||||
|
npm run dev # http://yazd-nobat.localhost:3000
|
||||||
|
```
|
||||||
|
1. `/blogs` → کلیک روی «چشم و گوش» → آدرس به `?tag=چشم و گوش` تغییر کند بدون reload کامل،
|
||||||
|
لیست فیلتر شود.
|
||||||
|
2. F5 روی همان آدرس → همان لیست و همان چیپ فعال.
|
||||||
|
3. کلیک روی «همه» → `tag` از URL حذف شود.
|
||||||
|
4. `/blogs?tag=nope` → «مقالهای یافت نشد»، چیپ «همه» فعال، بدون کرش کنسول.
|
||||||
|
5. `/blogs?tag=تنفس، آلرژی و عفونت` (کپیپیست از بردکرامب) → لیست درست، چیپ درست فعال.
|
||||||
|
|
||||||
|
### ۳. چیپها از endpoint facet، و فعالبودن بر اساس نام نه ایندکس
|
||||||
|
|
||||||
|
`components/blogs/title/index.js`:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
function Title({ selectedTag, onTagChange, cityId }) {
|
||||||
|
const [tags, setTags] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTags = async () => {
|
||||||
|
try {
|
||||||
|
// واژگان کامل تگهای مقالات منتشرشده در scope همین دامنه. استخراج از خودِ
|
||||||
|
// لیست مقالهها ممکن نیست: سقف limit برابر ۵۰ است و مقالات بیشتری وجود دارد.
|
||||||
|
const response = await request.getBlogTagFacets(
|
||||||
|
cityId ? { city_id: cityId } : {}
|
||||||
|
);
|
||||||
|
setTags(response?.data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching blog tags:", error);
|
||||||
|
setTags([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTags();
|
||||||
|
}, [cityId]);
|
||||||
|
|
||||||
|
if (tags.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="flex w-full overflow-auto hidden-scroll items-center justify-start gap-[8px] sm:gap-[10px] md:gap-[13px] lg:gap-[16px]">
|
||||||
|
<ItemTitle
|
||||||
|
name="همه"
|
||||||
|
isActive={!selectedTag}
|
||||||
|
onSelect={() => onTagChange(null)}
|
||||||
|
key="all"
|
||||||
|
/>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<ItemTitle
|
||||||
|
name={tag.name}
|
||||||
|
isActive={selectedTag === tag.name}
|
||||||
|
onSelect={() => onTagChange(tag.name)}
|
||||||
|
key={tag.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ItemTitle.js` هم به همین قرارداد سادهتر تغییر کند (`isActive`/`onSelect` بهجای
|
||||||
|
`activeBtn`/`idx`/`setActiveBtn`)؛ ایندکس دیگر معنایی ندارد چون حالت از نام تگ میآید:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
function ItemTitle({ name, isActive, onSelect }) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Button
|
||||||
|
className={`!py-[4px] md:!py-[6px] lg:!py-[8px] !px-[8px] md:!px-[12px] lg:!px-[16px] !shadow-none !rounded-[8px] !text-[14px] md:!text-[16px] !whitespace-nowrap ${
|
||||||
|
isActive ? "!bg-[#F17732] !text-[#FAFAFA]" : "!bg-[#F5F5F5] !text-[#3B3B3B]"
|
||||||
|
}`}
|
||||||
|
onClick={onSelect}
|
||||||
|
variant="contained"
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> کلاس فعلی `md:!text-[16px` براکت بسته ندارد (خط ۷ فایل) — یعنی این کلاس هیچوقت اعمال نشده.
|
||||||
|
> در بازنویسی اصلاح شود.
|
||||||
|
|
||||||
|
**نحوه تست:** Network tab → یک درخواست `api/v1/blogs/tags`؛ تعداد چیپها با تعداد تگهای
|
||||||
|
پاسخ برابر باشد. روی `yazd-nobat.localhost` تعداد چیپها باید ≤ حالت دامنهٔ ریشه باشد. کلیک روی
|
||||||
|
هر چیپ نباید «مقالهای یافت نشد» بدهد (چون facet فقط تگهای دارای پست همان scope را میدهد).
|
||||||
|
|
||||||
|
### ۴. بردکرامب صفحهٔ مقاله: دسته را لینک کن (HTML + JSON-LD)
|
||||||
|
|
||||||
|
`components/blog/head/index.js` — دسته را `<Link>` کن و ساختار را با `<nav>` مرتب کن:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<nav aria-label="مسیر" className="flex items-center flex-wrap justify-start gap-1">
|
||||||
|
<Link href="/blogs" className={crumbClass}>بلاگ</Link>
|
||||||
|
<span className={crumbClass}>{">"}</span>
|
||||||
|
{firstTag?.name && (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href={`/blogs?tag=${encodeURIComponent(firstTag.name)}`}
|
||||||
|
className={`${crumbClass} hover:text-[#525252]`}
|
||||||
|
>
|
||||||
|
{firstTag.name}
|
||||||
|
</Link>
|
||||||
|
<span className={crumbClass}>{">"}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-[#525252] text-[11px] sm:text-[13px] md:text-[15px] lg:text-[16px] font-normal">
|
||||||
|
{data?.title}
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
```
|
||||||
|
|
||||||
|
(`crumbClass` همان رشتهٔ کلاس تکراری `text-[#9B9B9B] text-[11px] …` است؛ یکبار بالای کامپوننت
|
||||||
|
تعریف شود تا چهار جا تکرار نشود.)
|
||||||
|
|
||||||
|
`app/blog/[slug]/page.js` — دسته را به JSON-LD اضافه کن. `item` هر سطح اجباری است، و URL باید
|
||||||
|
دقیقاً همان لینک HTML باشد:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// دستهٔ مقاله = تگ اول؛ همان چیزی که بردکرامب HTML نشان میدهد. مقالهٔ بدون تگ
|
||||||
|
// بردکرامب سهسطحی میگیرد — سطح خالی کل BreadcrumbList را نامعتبر میکند.
|
||||||
|
const categoryName = Array.isArray(blog?.tag) ? blog.tag[0]?.name : null;
|
||||||
|
|
||||||
|
const breadcrumbJsonLd = blog
|
||||||
|
? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
itemListElement: [
|
||||||
|
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
|
||||||
|
{ "@type": "ListItem", position: 2, name: "مقالات", item: `${origin}/blogs` },
|
||||||
|
...(categoryName
|
||||||
|
? [{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 3,
|
||||||
|
name: categoryName,
|
||||||
|
item: `${origin}/blogs?tag=${encodeURIComponent(categoryName)}`,
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: categoryName ? 4 : 3,
|
||||||
|
name: blog.title,
|
||||||
|
item: `${origin}/blog/${slug}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
```
|
||||||
|
|
||||||
|
**نحوه تست:**
|
||||||
|
1. صفحهٔ مقالهای با تگ → کلیک روی دسته → `/blogs?tag=…` با لیست فیلترشده باز شود.
|
||||||
|
2. `view-source` صفحه → JSON-LD بردکرامب چهار `ListItem` با `position` پشتسرهم ۱..۴ و
|
||||||
|
`item` غیرخالی دارد.
|
||||||
|
3. مقالهای بدون تگ (اگر در دیتای محلی نیست، `tags` یک مقاله را از پنل ادمین خالی کن) →
|
||||||
|
سه سطح، `position` ۱..۳، بدون `>` اضافی در HTML.
|
||||||
|
4. JSON-LD را در `https://search.google.com/test/rich-results` (تب Code) بچسبان → بدون خطا.
|
||||||
|
|
||||||
|
### ۵. «مطالب مرتبط»: همدسته کردن + مهار عرض و ارتفاع
|
||||||
|
|
||||||
|
**الف) واقعاً مرتبط شود** — در `app/blog/[slug]/page.js` اول با تگ مقاله بگیر، بعد با آخرین
|
||||||
|
مقالات تا ۴ تا پر کن (fallback، چون تگهایی مثل «قلب و عروق» فقط یک پست دارند):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const scopeQuery = scopeCityId != null ? `&city_id=${scopeCityId}` : "";
|
||||||
|
const categoryName = Array.isArray(blog?.tag) ? blog.tag[0]?.name : null;
|
||||||
|
|
||||||
|
// اول همدستهها؛ اگر دسته کمپست بود با آخرین مقالات پر میشود تا ستون خالی نماند.
|
||||||
|
const sameTagResponse = categoryName
|
||||||
|
? await fetchReq(
|
||||||
|
`${API_URL}/api/v1/blogs?page=1&limit=6&tag=${encodeURIComponent(categoryName)}${scopeQuery}`
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const latestResponse = await fetchReq(
|
||||||
|
`${API_URL}/api/v1/blogs?page=1&limit=6${scopeQuery}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const seen = new Set([blog?.uuid]);
|
||||||
|
const relatedBlogs = [
|
||||||
|
...(sameTagResponse?.data || []),
|
||||||
|
...(latestResponse?.data || []),
|
||||||
|
]
|
||||||
|
.filter((b) => !seen.has(b.uuid) && seen.add(b.uuid))
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(normalizeBlog);
|
||||||
|
```
|
||||||
|
|
||||||
|
**ب) عرض ستون را از عنوان بگیر و به layout بده** — `relatedContent/index.js`:
|
||||||
|
`lg:w-fit` (روی `div` و `ul`) با عرض ثابت جایگزین شود، مثلاً `lg:w-[320px] lg:shrink-0`.
|
||||||
|
در `components/blog/index.js` ستون متن `lg:w-[68%]` است؛ بعد از تثبیت عرض سایدبار، آن را به
|
||||||
|
`lg:flex-1 lg:min-w-0` تغییر بده تا مجموع دو ستون از `gap` سرریز نکند.
|
||||||
|
|
||||||
|
**ج) ردیفها را جمع کن** — `relatedContent/Item.js`:
|
||||||
|
- `lg:text-nowrap` حذف و `line-clamp-2` اضافه شود.
|
||||||
|
- `gap-[16px] … lg:gap-[24px]` بین عنوان و تاریخ به `gap-[6px]` کاهش یابد و
|
||||||
|
`items-center` روی `<li>` به `items-start` تغییر کند.
|
||||||
|
- `py-[12px] … lg:py-[20px]` به حداکثر `py-[12px]` کاهش یابد.
|
||||||
|
- عنوان از `<h2>` به `<h3>` تغییر کند (وظیفهٔ ۶).
|
||||||
|
|
||||||
|
**نحوه تست:** صفحهٔ مقالهٔ اسکرینشات (`چشم و گوش`) در عرض ۱۴۴۰px:
|
||||||
|
عنوانهای سایدبار حداکثر دو خط؛ عرض سایدبار با باز کردن دو مقالهٔ مختلف تغییر نکند؛ ارتفاع کل
|
||||||
|
ستون از ارتفاع تصویر شاخصِ مقاله کمتر یا نزدیک به آن باشد. سپس عرض ۳۷۵px (موبایل): سایدبار
|
||||||
|
تمامعرض زیر متن، بدون اسکرول افقی صفحه. در Network بررسی کن که درخواست
|
||||||
|
`?tag=<دسته>` واقعاً زده میشود و آیتمهای سایدبار همدستهاند.
|
||||||
|
|
||||||
|
### ۶. اصلاح سلسلهمراتب heading
|
||||||
|
|
||||||
|
- `components/blogs/latestArticles/Article.js:27` — `<h1>` → `<h2>`.
|
||||||
|
- `components/blogs/latestArticles/index.js:7` — «جدیدترین مقاله ها» از `<p>` به `<h1>` تبدیل
|
||||||
|
شود تا `/blogs` دقیقاً یک `<h1>` داشته باشد (کلاسها دستنخورده بمانند).
|
||||||
|
- `components/blog/relatedContent/Item.js:25` — `<h2>` → `<h3>`.
|
||||||
|
- `components/blog/head/index.js:36` — اندازهٔ `<h1>` مقاله به
|
||||||
|
`text-[18px] sm:text-[22px] md:text-[26px] lg:text-[30px]` افزایش یابد تا از
|
||||||
|
`.blog-content h2` (۲۰px) بهوضوح متمایز شود.
|
||||||
|
|
||||||
|
**نحوه تست:** در کنسول مرورگر روی هر صفحه:
|
||||||
|
```js
|
||||||
|
$$('h1,h2,h3,h4,h5,h6').map(h => h.tagName + ' · ' + h.textContent.trim().slice(0,40))
|
||||||
|
```
|
||||||
|
انتظار `/blogs`: یک `H1` («جدیدترین مقاله ها») و بقیه `H2`.
|
||||||
|
انتظار صفحهٔ مقاله: یک `H1` (عنوان مقاله)، `H2`های متن مقاله، `H3`های سایدبار.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **وابستگی به backend:** تا وقتی پرامپت `clinicpro/.claude/prompt/blog-tag-filter-and-facets.md`
|
||||||
|
اجرا نشده، وظایف ۲/۳/۵-الف قابل تست نیستند (فیلتر همیشه خالی برمیگردد و
|
||||||
|
`/api/v1/blogs/tags` وجود ندارد). ترتیب اجرا رعایت شود.
|
||||||
|
- **تغییر قرارداد در build خطا نمیدهد:** فراخوانی `getBlogTagFacets` روی endpointِ نساخته
|
||||||
|
فقط در رانتایم ۴۰۴ میگیرد و `catch` آن را میبلعد؛ حتماً Network tab بررسی شود، نه فقط
|
||||||
|
«صفحه بالا آمد».
|
||||||
|
- **`useSearchParams` بدون `<Suspense>` در Next 15 صفحه را میشکند** (خطای build/prerender).
|
||||||
|
اگر `app/blogs/loading.js` را بهعنوان fallback ترجیح میدهی، همان را در `Suspense` بگذار.
|
||||||
|
- **canonical دست نخورد:** `/blogs?tag=…` نباید canonical جدید تولید کند — لایهٔ layout
|
||||||
|
self-canonical روی `/blogs` میگذارد و همین درست است؛ صفحهٔ فیلترشده نسخهٔ دیگری از همان
|
||||||
|
لیست است، نه محتوای جدید. اگر `generateMetadata` صفحهٔ `/blogs` تغییر داده شد، این را نقض نکن.
|
||||||
|
- **encode/decode:** برای ساخت لینک از `encodeURIComponent` استفاده شود؛ برای خواندن،
|
||||||
|
`searchParams.get("tag")` خودش decode میکند — دوباره `decodeURIComponent` نزن (تگهای حاوی
|
||||||
|
`%` را خراب میکند).
|
||||||
|
- **`normalizeBlog` تفاوت `tags` و `tag` را میسازد:** پاسخ خام API فیلد `tags: ["نام"]` دارد؛
|
||||||
|
بعد از `normalizeBlog` میشود `tag: [{name}]`. در `title/index.js` (خام) از `name` پاسخ facet
|
||||||
|
استفاده کن و در صفحهٔ مقاله (نرمالشده) از `blog.tag[0].name`. قاطی کردن این دو، منبع
|
||||||
|
باگ فعلی است.
|
||||||
|
- **بدون کامپوننت جدید:** همهٔ تغییرها داخل کامپوننتهای موجود انجام شود؛ چیزی abstract نشود
|
||||||
|
«برای آینده». تنها قرارداد جدید، propهای `isActive`/`onSelect` در `ItemTitle` است که یک
|
||||||
|
API واقعاً سادهتر را جایگزین سه prop ایندکسمحور میکند.
|
||||||
|
- **الزام پروژه:** صفحهٔ جدیدی ساخته نمیشود و طراحی جدیدی معرفی نمیشود؛ همان تم، همان
|
||||||
|
رنگها (`#F17732`, `#3B3B3B`, `#9B9B9B`)، همان فونت Vazir و همان اسپیسینگهای موجود.
|
||||||
|
- **بعد از پایان:** `npm run lint` و `npm run build` هر دو سبز باشند.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<div dir="rtl" markdown="1">
|
||||||
|
|
||||||
|
# فعالسازی بلاگ شهر-محور (بعد از افزودن شهر در backend)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
**پیشنیاز قطعی:** `clinicpro/.claude/prompt/blog-city-field.md` اجرا و deploy شده باشد.
|
||||||
|
تا وقتی `GET /api/v1/blogs` فیلد شهر برنگرداند، این پرامپت کاری برای انجام ندارد.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
منطق شهر-محورِ بلاگ سمت فرانت **از قبل نوشته شده** ولی چون دادهاش وجود ندارد غیرفعال است. این پرامپت آن را فعال، تکمیل و تست میکند.
|
||||||
|
|
||||||
|
وضعیت داده هنگام نگارش: `GET /api/v1/blogs` صفر رکورد دارد (`totalRecords: 0`) و هیچ فیلد شهری ندارد.
|
||||||
|
|
||||||
|
## وضعیت فعلی (کدی که آمادهٔ فعالشدن است)
|
||||||
|
|
||||||
|
`app/blog/[slug]/page.js` — canonical و شهر از همان هلپر مشترک:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const blogCityId = extractEntityCityId(blog);
|
||||||
|
const blogCity = findCityById(blogCityId);
|
||||||
|
const origin = await getEntityOrigin(blogCityId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
alternates: { canonical: `${origin}/blog/${slug}` },
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON-LD مقاله فقط با وجود شهر `spatialCoverage` میگیرد:
|
||||||
|
|
||||||
|
```js
|
||||||
|
...(blogCity && {
|
||||||
|
spatialCoverage: { "@type": "Place", name: blogCity.name },
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
`components/blog/head/index.js` — برچسب بصری، فقط با `cityName`:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
`lib/domainHelpers.js` — استخراج شهر، هر سه شکل پاسخ را میپذیرد:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function extractEntityCityId(entity) {
|
||||||
|
if (!entity) return null;
|
||||||
|
const candidates = [
|
||||||
|
entity.city_id,
|
||||||
|
...(Array.isArray(entity.city) ? entity.city.map((c) => c?.id) : [entity.city?.id]),
|
||||||
|
...(Array.isArray(entity.address) ? entity.address.map((a) => a?.city?.id) : []),
|
||||||
|
].filter((id) => id != null);
|
||||||
|
const withDomain = candidates.find((id) => findDomainByCityId(id));
|
||||||
|
return withDomain ?? candidates[0] ?? null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`app/sitemap.js` — مسیریابی پست به sitemap دامنهٔ canonical خودش:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function getBlogUrls(baseUrl, scope, currentDomain) {
|
||||||
|
const blogs = await fetchAllPages('/api/v1/blogs');
|
||||||
|
return blogs
|
||||||
|
.filter((b) => b?.slug || b?.uuid)
|
||||||
|
.filter((b) => {
|
||||||
|
const cityDomain = findDomainByCityId(extractEntityCityId(b));
|
||||||
|
return cityDomain ? cityDomain === currentDomain : scope.isRoot;
|
||||||
|
})
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. تأیید سازگاری شکل پاسخ
|
||||||
|
|
||||||
|
اول پاسخ واقعی را ببین:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "https://clinic-pro.ir/api/v1/blogs?page=1&limit=5" | jq '.data.data[0]'
|
||||||
|
```
|
||||||
|
|
||||||
|
بررسی کن `extractEntityCityId` روی شکل واقعی جواب میدهد. اگر backend شکل دیگری داد (مثلاً `city` رشته بهجای آبجکت)، `extractEntityCityId` را گسترش بده — **نه** یک استخراج جدا برای بلاگ بنویس.
|
||||||
|
|
||||||
|
`normalizeBlog` در `helper/index.js` نباید فیلد شهر را حذف کند؛ اگر فیلدها را صریح map میکند، `city` را اضافه کن.
|
||||||
|
|
||||||
|
### ۲. لیست بلاگ per-domain
|
||||||
|
|
||||||
|
`app/blogs/page.js` روی هر دامنهٔ شهری باید پستهای آن شهر + سراسری را بگیرد. با پارامتر `city_id` که backend اضافه میکند:
|
||||||
|
|
||||||
|
- روی دامنهٔ شهری: `city_id` شهر همان دامنه (از `getStateInfo`)
|
||||||
|
- روی دامنهٔ ریشه (`isRoot`): بدون پارامتر — همهٔ پستها
|
||||||
|
|
||||||
|
canonical لیست بلاگ **self روی همان دامنه** بماند (C1-a). این از قبل درست است — کانونیکال hardcode شده به دامنهٔ اصلی قبلاً حذف شده:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// C1-a — لیست بلاگ per-domain است (پستهای همان شهر + سراسری)؛
|
||||||
|
// canonical لایهٔ layout (self روی همان دامنه) درست است.
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳. عنوان و متادیتای شهرمحور
|
||||||
|
|
||||||
|
`generateMetadata` صفحهٔ پست: اگر پست شهر دارد، نام شهر در `title`/`description` بیاید. اگر سراسری است، هیچ نام شهری اضافه نشود (نه نام شهرِ دامنهٔ سروکننده).
|
||||||
|
|
||||||
|
### ۴. تست
|
||||||
|
|
||||||
|
با دادهٔ واقعی (حداقل یک پست شهری + یک پست سراسری) تأیید کن:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
S=<slug-پست-یاسوجی>
|
||||||
|
G=<slug-پست-سراسری>
|
||||||
|
|
||||||
|
# پست شهری: canonical به دامنهٔ شهر، روی هر دامنهای
|
||||||
|
curl -s "https://yasuj-nobat.ir/blog/$S" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir
|
||||||
|
curl -s "https://nobat724.com/blog/$S" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir
|
||||||
|
curl -s "https://yasuj-nobat.ir/blog/$S" | grep -o "یاسوج" # نام شهر در HTML قابلمشاهده
|
||||||
|
curl -s "https://yasuj-nobat.ir/blog/$S" | grep -o 'spatialCoverage'
|
||||||
|
|
||||||
|
# پست سراسری: self-canonical، بدون نام شهر
|
||||||
|
curl -s "https://nobat724.com/blog/$G" | grep -o 'rel="canonical" href="[^"]*"' # → nobat724.com
|
||||||
|
curl -s "https://yasuj-nobat.ir/blog/$G" | grep -c 'spatialCoverage' # → 0
|
||||||
|
|
||||||
|
# sitemap: هر پست فقط در دامنهٔ canonical خودش
|
||||||
|
curl -s https://yasuj-nobat.ir/sitemap.xml | grep -c "$S" # → 1
|
||||||
|
curl -s https://nobat724.com/sitemap.xml | grep -c "$S" # → 0
|
||||||
|
curl -s https://nobat724.com/sitemap.xml | grep -c "$G" # → 1
|
||||||
|
curl -s https://nobat724.com/sitemap.xml | grep -c "/blog/" # → بیشتر از ۰
|
||||||
|
```
|
||||||
|
|
||||||
|
تست واحد برای `extractEntityCityId` روی شکل واقعی پاسخ بلاگ به `lib/domainHelpers.test.js` اضافه کن.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **پست سراسری حالت دائمی است، نه استثنا.** پستی بدون شهر باید روی دامنهٔ اصلی self-canonical شود و در همهٔ لیستها دیده شود. هرگز شهرِ دامنهٔ سروکننده را به پست بدون شهر نسبت نده.
|
||||||
|
- `findCityById` رکورد ریشه (`id: 600` = `nobat724.com`) را شهر حساب نمیکند و `null` برمیگرداند — یعنی پستی که بهاشتباه به رکورد ریشه وصل شده، سراسری تلقی میشود. رفتار درست است.
|
||||||
|
- برچسب بصری شهر با تایپوگرافی «نویسنده/تاریخ» همخانواده است؛ عنصر بصری ناهماهنگ اضافه نکن.
|
||||||
|
- الگوی Soft-404 (`app/blog/[slug]/page.js`) بعد از تغییرات همچنان باید کار کند:
|
||||||
|
`curl -o /dev/null -w "%{http_code}" https://nobat724.com/blog/invalid-xxx` → `404`.
|
||||||
|
توجه: `app/blog/[slug]/loading.js` عمداً حذف شده — دوباره اضافهاش نکن، وگرنه Soft-404 برمیگردد.
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
# سینک کامل صفحهٔ بلاگ با بکاند + تصویر پیشفرض برند + باطلسازی کش
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی)
|
||||||
|
|
||||||
|
پرامپت همتا (cross-repo، **اول اجرا شود**): `clinicpro/.claude/prompt/blog-admin-edit-and-dark-mode.md` — اندپوینت ادمین و فراخوانندهٔ webhook آنجا ساخته میشود؛ **مصرفکنندهٔ** webhook اینجاست.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
کاربر گزارش داده صفحهٔ بلاگ سایت (`https://nobat724.com/blog/<uuid>`) همهٔ اطلاعاتی را که در پنل ادمین مدیریت میشود نشان نمیدهد، و مقالهٔ بدون تصویر شاخص با تصویر پیشفرض نامناسب نمایش داده میشود.
|
||||||
|
|
||||||
|
بررسی کد نشان میدهد بکاند در `Blog::toArray()` این فیلدها را برمیگرداند:
|
||||||
|
|
||||||
|
```
|
||||||
|
uuid, title, slug, summary, body, image_url, tags, sources, status,
|
||||||
|
review_status, reviewer, reviewed_at, review_note, topic_slug,
|
||||||
|
meta_title, meta_description, primary_keyword, secondary_keywords, faq,
|
||||||
|
internal_links, external_links, reading_time, canonical_url, og_image,
|
||||||
|
representation, author, city, created_at, updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
اما صفحهٔ بلاگ فقط `title`، `author`، `created`، `tag[0]` (در بردکرامب)، `city` و `body` را رندر میکند.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
### مشکل ۱ — فیلدهایی که رندر نمیشوند
|
||||||
|
|
||||||
|
| فیلد بکاند | وضعیت فعلی در سایت |
|
||||||
|
|---|---|
|
||||||
|
| `summary` (خلاصه) | ❌ فقط در متادیتا؛ در HTML صفحه هیچجا نیست |
|
||||||
|
| `tags` | ⚠️ فقط تگ اول در بردکرامب (`components/blog/head/index.js:5`)؛ فهرست تگها نیست |
|
||||||
|
| `reading_time` | ❌ رندر نمیشود |
|
||||||
|
| `sources` (منابع، سیگنال E-E-A-T) | ❌ رندر نمیشود |
|
||||||
|
| `faq` | ⚠️ فقط داخل JSON-LD (`app/blog/[slug]/page.js:173`)؛ در HTML قابلمشاهده نیست — گوگل برای rich result نیازمند حضور مرئی همان پرسش/پاسخهاست، پس FAQPage فعلی در معرض بیاعتبار شدن است |
|
||||||
|
| `internal_links` / `external_links` | ❌ رندر نمیشود |
|
||||||
|
| `status` | ✅ درست: پستِ غیرمنتشر از بکاند ۴۰۴ میگیرد |
|
||||||
|
| `slug` / `meta_*` / `canonical_url` / `og_image` | ✅ در `generateMetadata` مصرف میشود |
|
||||||
|
|
||||||
|
**نکتهٔ خلاف درخواست کاربر:** بلاگ در بکاند **دستهبندی (category) ندارد**؛ فقط ستون json `tags`. پس «دستهبندیها» با نمایش تگها پوشش داده میشود. ساخت Entity دستهبندی تسک جدا و migration جدا میخواهد و در محدودهٔ این پرامپت نیست.
|
||||||
|
|
||||||
|
### مشکل ۲ — تصویر پیشفرض
|
||||||
|
|
||||||
|
سه fallback ناهماهنگ و نامناسب در کد وجود دارد و در صفحهٔ جزئیات اصلاً fallback ای نیست:
|
||||||
|
|
||||||
|
`components/blog/detail/Caption.js:7,10`
|
||||||
|
```jsx
|
||||||
|
const cover = data?.images?.[0]?.url ? imageUrl(data.images[0].url) : "";
|
||||||
|
...
|
||||||
|
{cover && cover.trim() !== "" && (
|
||||||
|
```
|
||||||
|
→ مقالهٔ بدون تصویر **هیچ تصویری** ندارد.
|
||||||
|
|
||||||
|
`components/blogs/latestArticles/Article.js:8-10`
|
||||||
|
```jsx
|
||||||
|
const imageSrc = data.images?.[0]?.url
|
||||||
|
? imageUrl(data.images[0].url, "/assets/images/cover-blog-1.png")
|
||||||
|
: "/assets/images/cover-blog-1.png";
|
||||||
|
```
|
||||||
|
|
||||||
|
`components/blog/relatedContent/Item.js:8-10`
|
||||||
|
```jsx
|
||||||
|
const cover = data?.images?.[0]?.url
|
||||||
|
? imageUrl(data.images[0].url)
|
||||||
|
: "/assets/images/cover-blog-1.png";
|
||||||
|
```
|
||||||
|
|
||||||
|
`app/blog/[slug]/page.js:16`
|
||||||
|
```js
|
||||||
|
const FALLBACK_IMG = "/assets/images/og-image.png";
|
||||||
|
```
|
||||||
|
|
||||||
|
و پیشفرضِ خودِ هلپر یک آواتار انسان است (`helper/index.js`):
|
||||||
|
```js
|
||||||
|
export const imageUrl = (url, fallback = "/assets/images/user.png") => {
|
||||||
|
```
|
||||||
|
یعنی هر جای دیگری که `imageUrl(blog.image_url)` بدون آرگومان دوم صدا زده شود، عکسِ «کاربر» را برای مقاله نشان میدهد.
|
||||||
|
|
||||||
|
### مشکل ۳ — تأخیر تا یک ساعت در نمایش تغییرات ادمین
|
||||||
|
|
||||||
|
`app/blog/[slug]/page.js:21-32`
|
||||||
|
```js
|
||||||
|
const getBlog = cache(async (slug, cityId) => {
|
||||||
|
const query = cityId != null ? `?city_id=${cityId}` : "";
|
||||||
|
const res = await fetch(`${API_URL}/api/v1/blog/${slug}${query}`, {
|
||||||
|
next: { revalidate: 3600, tags: [`blog-${slug}`] },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
tag تعریف شده ولی **هیچکس آن را باطل نمیکند** (`app/api/` فقط `auth` دارد). پس تغییر ادمین تا ۶۰ دقیقه دیده نمیشود.
|
||||||
|
|
||||||
|
### مشکل ۴ — استایل محتوای CKEditor حذف میشود
|
||||||
|
|
||||||
|
`lib/sanitize.js` تگهای `figure`/`figcaption` را در `ALLOWED_TAGS` ندارد و `class`/`style` را هم در `ALLOWED_ATTR` ندارد. خروجی جدول/تصویر CKEditor به شکل `<figure class="table"><table>…</table></figure>` است؛ DOMPurify با `KEEP_CONTENT` پیشفرض، محتوای داخلی را نگه میدارد ولی wrapper و کلاسها را حذف میکند → جدول و تصویرِ داخل متن بدون هیچ استایلی و چسبیده رندر میشوند. (این حذف داده نیست، حذف ساختار/استایل است — واقعیت را همینطور گزارش کن.)
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
|
||||||
|
- ✅ **موفق:**
|
||||||
|
- در `/blog/<slug>` یک مقالهٔ کامل: خلاصه، فهرست تگها، زمان مطالعه، تصویر شاخص، متن، بخش «سؤالات متداول» مرئی، بخش «منابع» با لینکهای `sources` — همه رندر میشوند و مقادیرشان دقیقاً با پاسخ `GET /api/v1/blog/{slug}` یکی است.
|
||||||
|
- مقالهٔ بدون `image_url` در صفحهٔ جزئیات، کارتهای لیست، مقالات مرتبط و OG/Twitter همگی **همان یک** تصویر پیشفرض برنددار را نشان میدهند.
|
||||||
|
- `POST /api/revalidate` با هدر صحیح → `200 {"revalidated":true}` و بلافاصله پس از آن، صفحهٔ بلاگ محتوای جدید را نشان میدهد (بدون انتظار یکساعته).
|
||||||
|
- `npm run build` و `npm run lint` سبز.
|
||||||
|
- ❌ **خطا:**
|
||||||
|
- `POST /api/revalidate` بدون هدر یا با سکرت غلط → `401` و هیچ باطلسازیای انجام نشود.
|
||||||
|
- `POST /api/revalidate` با بدنهٔ نامعتبر (بدون `tags` یا `tags` غیرآرایه) → `400`.
|
||||||
|
- اسلاگ ناموجود → همان `notFound()` فعلی (۴۰۴)، بدون خطای رندر.
|
||||||
|
- ⚠️ **مرزی:**
|
||||||
|
- مقالهای با `faq: []` و `sources: []` و `tags: []` → هیچ سکشن خالی یا هدینگ بیمحتوا رندر نشود (نه «سؤالات متداول» خالی، نه `<ul>` خالی).
|
||||||
|
- مقالهای که `reading_time = null` دارد → برچسب زمان مطالعه نمایش داده نشود.
|
||||||
|
- مقالهٔ شهریافته روی دامنهٔ شهرِ دیگر → همچنان ۴۰۴ (رفتار فعلی `domainScopeCityId` نباید تغییر کند).
|
||||||
|
- `image_url` مطلق (`https://…`) → `imageUrl()` باید دستنخورده برگرداند؛ `/uploads/...` → با `NEXT_PUBLIC_API_URL` پیشوند بخورد.
|
||||||
|
- `og_image` وقتی ست است بر `image_url` مقدم بماند (رفتار فعلی `page.js:75-79` حفظ شود).
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|---|---|
|
||||||
|
| `nobat724_front/helper/index.js` | ثابت `BLOG_FALLBACK_IMG` + استفاده در `normalizeBlog` |
|
||||||
|
| `nobat724_front/components/blog/detail/Caption.js` | کاور با fallback + خلاصه + استایل محتوا |
|
||||||
|
| `nobat724_front/components/blog/head/index.js` | فهرست تگها + زمان مطالعه |
|
||||||
|
| `nobat724_front/components/blog/detail/Faq.js` | **جدید** — FAQ مرئی |
|
||||||
|
| `nobat724_front/components/blog/detail/Sources.js` | **جدید** — منابع |
|
||||||
|
| `nobat724_front/components/blog/detail/index.js` | چیدن کامپوننتهای جدید |
|
||||||
|
| `nobat724_front/components/blogs/latestArticles/Article.js`, `components/blog/relatedContent/Item.js` | یکسانسازی fallback |
|
||||||
|
| `nobat724_front/app/blog/[slug]/page.js` | `FALLBACK_IMG` مشترک + tag های کش |
|
||||||
|
| `nobat724_front/app/api/revalidate/route.js` | **جدید** — webhook باطلسازی |
|
||||||
|
| `nobat724_front/lib/sanitize.js` | افزودن `figure`/`figcaption` |
|
||||||
|
| `nobat724_front/app/globals.css` | استایل محتوای مقاله (`.blog-content`) |
|
||||||
|
| `nobat724_front/public/assets/images/blog-default-cover.png` | **جدید** — تصویر پیشفرض ۱۲۰۰×۶۳۰ |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
`nobat724_front/components/blog/detail/Caption.js` (کل فایل):
|
||||||
|
```jsx
|
||||||
|
import React from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { sanitizeHtml } from "@/lib/sanitize";
|
||||||
|
import { imageUrl } from "@/helper";
|
||||||
|
|
||||||
|
function Caption({ data }) {
|
||||||
|
const cover = data?.images?.[0]?.url ? imageUrl(data.images[0].url) : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{cover && cover.trim() !== "" && (
|
||||||
|
<div className="relative w-full aspect-video rounded-[8px] overflow-hidden">
|
||||||
|
<Image src={cover} alt={data?.title || "blog cover"} fill className="object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data?.body?.value && (
|
||||||
|
<div
|
||||||
|
className="my-[12px] sm:my-[16px] md:my-[20px] lg:my-[24px] text-[#525252] text-[14px] md:text-[15px] lg:text-[16px] font-normal leading-[26px] sm:leading-[28px] md:leading-[30px] lg:leading-[32px]"
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizeHtml(data.body.value) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Caption;
|
||||||
|
```
|
||||||
|
|
||||||
|
`nobat724_front/components/blog/detail/index.js` (کل فایل):
|
||||||
|
```jsx
|
||||||
|
import Caption from "./Caption";
|
||||||
|
import SocialMedia from "./SocialMedia";
|
||||||
|
import CommentUser from "./commentUser";
|
||||||
|
|
||||||
|
function Detail({ data }) {
|
||||||
|
return (
|
||||||
|
<div className="w-full lg:w-[68%]">
|
||||||
|
<Caption data={data} />
|
||||||
|
<SocialMedia />
|
||||||
|
{/* <CommentUser data={data} loading={false} /> */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`nobat724_front/helper/index.js` (نرمالساز + هلپر تصویر):
|
||||||
|
```js
|
||||||
|
export const normalizeBlog = (blog) => {
|
||||||
|
if (!blog) return null;
|
||||||
|
return {
|
||||||
|
...blog,
|
||||||
|
images: blog.image_url ? [{ url: blog.image_url }] : [],
|
||||||
|
tag: Array.isArray(blog.tags)
|
||||||
|
? blog.tags.map((t) => (typeof t === "string" ? { name: t } : t))
|
||||||
|
: [],
|
||||||
|
created: blog.created_at ?? blog.created ?? null,
|
||||||
|
body: blog.body && typeof blog.body === "object" ? blog.body : { value: blog.body ?? "" },
|
||||||
|
author: blog.author && typeof blog.author === "object" ? blog.author.name : blog.author ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const imageUrl = (url, fallback = "/assets/images/user.png") => {
|
||||||
|
if (!url) return fallback;
|
||||||
|
if (/^https?:\/\//.test(url)) return url;
|
||||||
|
if (url.startsWith("/uploads")) {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_URL || "";
|
||||||
|
return `${base}${url}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`nobat724_front/lib/sanitize.js` (بخش مرتبط):
|
||||||
|
```js
|
||||||
|
return DOMPurify.sanitize(html, {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
"p", "br", "strong", "em", "b", "i", "u", "ul", "ol", "li", "a",
|
||||||
|
"h2", "h3", "h4", "h5", "blockquote", "img", "span", "div",
|
||||||
|
"table", "thead", "tbody", "tr", "td", "th",
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: ["href", "target", "rel", "src", "alt", "title"],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. ساخت تصویر پیشفرض برنددار
|
||||||
|
|
||||||
|
`sharp@0.34.5` از قبل در `package.json` هست. یک اسکریپت یکبارمصرف در `scripts/make-blog-cover.mjs` بنویس که از `public/assets/images/logo.png` روی پسزمینهٔ برند، فایل `public/assets/images/blog-default-cover.png` با ابعاد **۱۲۰۰×۶۳۰** (نسبت استاندارد Open Graph، سازگار با `aspect-video`) بسازد:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const W = 1200, H = 630;
|
||||||
|
// رنگ برند را از app/globals.css یا tailwind.config برداشت کن — هاردکد نکن اگر توکن موجود است.
|
||||||
|
const BG = { r: 0x0f, g: 0x4c, b: 0x81, alpha: 1 };
|
||||||
|
|
||||||
|
const logo = await sharp("public/assets/images/logo.png").resize({ width: 420 }).toBuffer();
|
||||||
|
await sharp({ create: { width: W, height: H, channels: 4, background: BG } })
|
||||||
|
.composite([{ input: logo, gravity: "center" }])
|
||||||
|
.png()
|
||||||
|
.toFile("public/assets/images/blog-default-cover.png");
|
||||||
|
```
|
||||||
|
|
||||||
|
خروجی PNG باید کمیت شود (commit) و اسکریپت هم بماند تا قابل بازتولید باشد. اگر رنگ برند در CSS پیدا نشد، از رنگ لوگو نمونه بگیر و در گزارش بگو کدام مقدار استفاده شد.
|
||||||
|
|
||||||
|
**نحوه تست:** `node scripts/make-blog-cover.mjs` → فایل ساخته شود؛ `sips -g pixelWidth -g pixelHeight public/assets/images/blog-default-cover.png` → `1200 × 630`. تصویر را باز کن و در گزارش بگو لوگو خوانا و مرکز است.
|
||||||
|
|
||||||
|
### ۲. یک منبع واحد برای fallback تصویر بلاگ
|
||||||
|
|
||||||
|
در `helper/index.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// تصویر پیشفرض همهٔ مقالهها — لیست، کارت، جزئیات، OG و توییتر همگی همین را
|
||||||
|
// میگیرند تا هیچ مقالهای بدون تصویر (یا با آواتار کاربر) نمایش داده نشود.
|
||||||
|
export const BLOG_FALLBACK_IMG = "/assets/images/blog-default-cover.png";
|
||||||
|
|
||||||
|
export const blogCover = (blog) =>
|
||||||
|
imageUrl(blog?.og_image || blog?.image_url || blog?.images?.[0]?.url, BLOG_FALLBACK_IMG);
|
||||||
|
```
|
||||||
|
|
||||||
|
سپس جایگزینی در همهٔ نقاط مصرف:
|
||||||
|
- `components/blog/detail/Caption.js` → `const cover = blogCover(data);` و شرط `{cover && ...}` حذف شود (همیشه تصویر هست).
|
||||||
|
- `components/blogs/latestArticles/Article.js:8-10` → `const imageSrc = blogCover(data);`
|
||||||
|
- `components/blog/relatedContent/Item.js:8-10` → `const cover = blogCover(data);`
|
||||||
|
- `app/blog/[slug]/page.js:16,75-79` → `FALLBACK_IMG` حذف و از `BLOG_FALLBACK_IMG` استفاده شود؛ منطق تقدم `og_image` روی `image_url` داخل `blogCover` است، پس بلوک شرطی سهطبقهٔ فعلی ساده میشود.
|
||||||
|
- ارجاعهای `cover-blog-1.png` / `user.png` برای بلاگ حذف شوند. (فایلهای `head-blogs-*.png` در `components/blogs/Head.js` هستند که **کد مرده است** — کل کامپوننت `return null` میکند و بقیهاش کامنت است؛ دست نزن، فقط در گزارش ذکر کن.)
|
||||||
|
|
||||||
|
**نحوه تست:** با `npm run dev` (روی `http://yazd-nobat.localhost:3000`) یک مقالهٔ بدون `image_url` باز کن → کاور پیشفرض دیده شود؛ `curl -s <url> | grep 'og:image'` → مسیر `blog-default-cover.png`؛ همان مقاله در `/blogs` و در «مقالات مرتبط» هم همان تصویر را داشته باشد.
|
||||||
|
|
||||||
|
### ۳. رندر فیلدهای گمشده
|
||||||
|
|
||||||
|
**سربرگ** (`components/blog/head/index.js`) — بعد از ردیف نویسنده/تاریخ:
|
||||||
|
- `data.reading_time` → «زمان مطالعه: X دقیقه» با همان تایپوگرافی `text-[#9B9B9B]`.
|
||||||
|
- فهرست کامل تگها به شکل چیپهای لینکدار (اگر صفحهٔ فیلتر تگ ندارد، به `/blogs` لینک بده یا بدون لینک رندر کن — تصمیم را در کد کامنت کن).
|
||||||
|
|
||||||
|
**خلاصه** — در `Caption.js` بالای بدنه، فقط اگر `data.summary` غیرخالی باشد:
|
||||||
|
```jsx
|
||||||
|
{data?.summary && (
|
||||||
|
<p className="mt-[16px] text-[#3B3B3B] text-[15px] md:text-[16px] font-medium leading-[30px] border-r-[3px] border-[#E5E5E5] pr-[12px]">
|
||||||
|
{data.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**FAQ مرئی** — `components/blog/detail/Faq.js` جدید، با `<details>/<summary>` بومی (بدون وابستگی جدید)، فقط وقتی حداقل یک آیتم معتبر (`f.q && f.a`) وجود دارد. همان آرایهای که در `app/blog/[slug]/page.js` به JSON-LD میرود باید اینجا هم رندر شود — تطابق متن مرئی با JSON-LD شرط اعتبار FAQPage است.
|
||||||
|
|
||||||
|
**منابع** — `components/blog/detail/Sources.js` جدید: `data.sources` آرایهای از `{url, title}` است؛ لینکها با `target="_blank" rel="nofollow noopener"`. فقط وقتی آرایه غیرخالی است رندر شود.
|
||||||
|
|
||||||
|
هر دو در `components/blog/detail/index.js` بعد از `Caption` و قبل از `SocialMedia` قرار بگیرند.
|
||||||
|
|
||||||
|
**نحوه تست:** یک مقالهٔ واقعی از API بگیر و خروجی را با صفحه مقایسه کن:
|
||||||
|
```bash
|
||||||
|
curl -s "$NEXT_PUBLIC_API_URL/api/v1/blog/<slug>" | jq '.data.data | {summary,tags,reading_time,faq,sources}'
|
||||||
|
curl -s "http://yazd-nobat.localhost:3000/blog/<slug>" | grep -c "سؤالات متداول"
|
||||||
|
```
|
||||||
|
سپس مقالهای با `faq: []` و `sources: []` باز کن → هیچ هدینگ خالی نباشد. JSON-LD صفحه را در [Rich Results Test](https://search.google.com/test/rich-results) یا با `jq` اعتبارسنجی کن.
|
||||||
|
|
||||||
|
### ۴. استایل محتوای مقاله + sanitizer
|
||||||
|
|
||||||
|
در `lib/sanitize.js` تگهای `figure` و `figcaption` به `ALLOWED_TAGS` اضافه شوند (خروجی جدول/تصویر CKEditor). **هشدار داخل همان فایل را جدی بگیر:** این فهرست آینهٔ `clinicpro-crawler/content/composer.py` است — همانجا هم بهروزرسانی و در گزارش ذکر کن.
|
||||||
|
|
||||||
|
در `app/globals.css` یک کلاس `.blog-content` تعریف کن که `h2/h3/ul/ol/table/img/blockquote/a` داخل بدنهٔ مقاله را استایل بدهد (چون `class` و `style` عمداً از HTML ورودی حذف میشوند، استایل باید از سمت سایت بیاید)، و در `Caption.js` روی همان `div` مربوط به `dangerouslySetInnerHTML` بنشیند.
|
||||||
|
|
||||||
|
**نحوه تست:** در پنل ادمین یک جدول و یک لیست و یک نقلقول در مقاله درج کن، ذخیره کن، صفحهٔ سایت را ببین: جدول با حاشیه، لیست با bullet، نقلقول با نوار کناری. اسکرینشات ضمیمه شود.
|
||||||
|
|
||||||
|
### ۵. Webhook باطلسازی کش (مصرفکنندهٔ قرارداد بکاند)
|
||||||
|
|
||||||
|
`app/api/revalidate/route.js` جدید:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { revalidateTag, revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* باطلسازی on-demand کش ISR بلاگ. بکاند (clinicpro) پس از هر create/update/
|
||||||
|
* delete/review این مسیر را صدا میزند تا تغییر پنل ادمین بدون تأخیر روی سایت بیاید.
|
||||||
|
* قرارداد: POST { tags: string[] } + هدر X-Revalidate-Secret
|
||||||
|
*/
|
||||||
|
export async function POST(request) {
|
||||||
|
const secret = process.env.REVALIDATE_SECRET;
|
||||||
|
if (!secret || request.headers.get("x-revalidate-secret") !== secret) {
|
||||||
|
return NextResponse.json({ revalidated: false }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const tags = body?.tags;
|
||||||
|
if (!Array.isArray(tags) || tags.length === 0) {
|
||||||
|
return NextResponse.json({ revalidated: false, error: "tags required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.forEach((t) => revalidateTag(String(t)));
|
||||||
|
if (tags.includes("blog-list")) revalidatePath("/blogs");
|
||||||
|
|
||||||
|
return NextResponse.json({ revalidated: true, tags });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
و در `app/blog/[slug]/page.js` هر دو tag را ثبت کن تا بکاند بتواند با slug **یا** uuid باطل کند (URL سایت uuid است، ولی `blog-<slug>` هم از سمت بکاند میآید):
|
||||||
|
|
||||||
|
```js
|
||||||
|
next: { revalidate: 3600, tags: [`blog-${slug}`, "blog-list"] },
|
||||||
|
```
|
||||||
|
|
||||||
|
`REVALIDATE_SECRET` را به `.env.example`/مستند محیط اضافه کن؛ مقدارش باید با `REVALIDATE_WEBHOOK_SECRET` سمت `clinicpro` یکی باشد.
|
||||||
|
|
||||||
|
**نحوه تست:**
|
||||||
|
```bash
|
||||||
|
npm run build && npm run start
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:3000/api/revalidate # → 401
|
||||||
|
curl -s -X POST localhost:3000/api/revalidate -H "X-Revalidate-Secret: $REVALIDATE_SECRET" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"tags":["blog-list"]}' # → {"revalidated":true,...}
|
||||||
|
curl -s -X POST localhost:3000/api/revalidate -H "X-Revalidate-Secret: $REVALIDATE_SECRET" \
|
||||||
|
-H 'Content-Type: application/json' -d '{}' -o /dev/null -w '%{http_code}\n' # → 400
|
||||||
|
```
|
||||||
|
سپس تست انتها-به-انتها: عنوان مقاله را در `/admin/blogs` عوض کن و بلافاصله صفحهٔ سایت را رفرش کن → عنوان جدید (نه ۶۰ دقیقه بعد).
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **ترتیب اجرا:** پرامپت `clinicpro` اول. بدون آن، تست انتها-به-انتهای وظیفهٔ ۵ ممکن نیست (فراخوانندهٔ webhook آنجاست).
|
||||||
|
- بلاگ در بکاند دستهبندی ندارد؛ `tags` نقش آن را دارد. اگر کاربر واقعاً Entity دستهبندی میخواهد، تسک جدا با migration لازم است — در این پرامپت نیست و نباید سرخود ساخته شود.
|
||||||
|
- `components/blogs/Head.js` کد مرده است (`return null` + بقیه کامنت). fallbackهای `head-blogs-*.png` داخل آن هیچ اثری ندارند؛ تغییرشان ندهید، فقط در گزارش پایانی ذکر شود.
|
||||||
|
- `imageUrl()` هلپر عمومی است و جاهای دیگر (پزشک، کلینیک) روی پیشفرض `user.png` حساب میکنند؛ **امضای آن را عوض نکن** — `blogCover()` جدید فقط برای بلاگ است. (اصل باز/بسته: رفتار جدید با تابع جدید، نه با `if` روی نوع.)
|
||||||
|
- `next/image` برای دامنهٔ API نیاز به `remotePatterns` در `next.config` دارد؛ اگر کاور از `NEXT_PUBLIC_API_URL` میآید و بیلد خطا داد، پیکربندی موجود را چک کن (قبل از تغییر، `next.config.*` را بخوان).
|
||||||
|
- هیچ سکشن خالی رندر نشود — این هم معیار پذیرش مرزی است و هم مسئلهٔ SEO (هدینگ بیمحتوا).
|
||||||
|
- بعد از پیادهسازی: `npm run lint` و `npm run build` باید سبز باشند، و تغییر قرارداد کش را در `nobat724_front` README/محیط مستند کن.
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
# تسک ۰۰ب — سازگارسازی سایت عمومی با نوبتدهی سرویسی
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی)
|
||||||
|
|
||||||
|
پرامپت همتا (بکاند، **باید اول تمام شده باشد**):
|
||||||
|
`clinicpro/.claude/prompt/booking-engine-task-runner.md` → تسک ۰۰
|
||||||
|
|
||||||
|
منبع کامل این تسک — چهار فایل، **همه را بخوان**:
|
||||||
|
|
||||||
|
```
|
||||||
|
clinicpro/docs/new_feture/taskes/task-00b-nobat724-service-mode/task.md
|
||||||
|
clinicpro/docs/new_feture/taskes/task-00b-nobat724-service-mode/architecture.md
|
||||||
|
clinicpro/docs/new_feture/taskes/task-00b-nobat724-service-mode/implementation_notes.md
|
||||||
|
clinicpro/docs/new_feture/taskes/task-00b-nobat724-service-mode/checklist.md ← وضعیت
|
||||||
|
```
|
||||||
|
|
||||||
|
و سه سند حاکم:
|
||||||
|
|
||||||
|
```
|
||||||
|
clinicpro/docs/new_feture/taskes/_shared/red-lines.md
|
||||||
|
clinicpro/docs/new_feture/taskes/_shared/ui-conventions.md
|
||||||
|
clinicpro/docs/new_feture/taskes/_shared/definition-of-done.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
سایت حالت نوبتدهی سرویسی را **میشناسد** — `components/appointment/index.js` روش را
|
||||||
|
per محل تشخیص میدهد، `components/appointment/service/index.js` مرحلهٔ انتخاب سرویس را
|
||||||
|
نشان میدهد، و `lib/appointmentSlots.js` پاسخ `appointment-service-slots` را به قالب
|
||||||
|
اسلات تبدیل میکند. ولی سه دسته مشکل دارد: انحراف از تم، محاسبهٔ موازی مدت در فرانت، و
|
||||||
|
نبود سرویس/مدت در پنل کاربر.
|
||||||
|
|
||||||
|
تسک ۰۰ در `clinicpro` سه endpoint جدید ساخته (`service-reschedule`، `convert-reserve`،
|
||||||
|
پارامتر `exclude_appointment_uuid`) و دو ستون `service_total_minutes`/`service_buffer_minutes`
|
||||||
|
اضافه کرده. این تسک سایت را با آنها همگام میکند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
پنج شکاف مشخص:
|
||||||
|
|
||||||
|
| # | شکاف | فایل |
|
||||||
|
|---|---|---|
|
||||||
|
| ۱ | چهار رنگ hard-code در مرحلهٔ انتخاب سرویس → دارکمود میشکند | `components/appointment/service/index.js` |
|
||||||
|
| ۲ | مدت با `reduce` در فرانت حساب میشود، موازی با `total_duration_minutes` بکاند | همان فایل |
|
||||||
|
| ۳ | `adaptServiceSlots` همهٔ زمانها را در یک تب با برچسب ثابت «زمانهای خالی» میریزد و `end_time` را اشتباه میدهد | `lib/appointmentSlots.js` |
|
||||||
|
| ۴ | پنل کاربر نام سرویس و مدت نوبت را نشان نمیدهد | `components/dashboard/userAccount/sidebars/turns/*` |
|
||||||
|
| ۵ | جابهجایی نوبت سرویسی از پنل کاربر وجود ندارد | `.../turns/isTurnsDetails/ButtonData.js` |
|
||||||
|
|
||||||
|
شکاف ۲ مهمترین است: وقتی تسک ۰۴ فرمول را به «زمان تنها / زمان اضافه» عوض کند، سایت
|
||||||
|
عدد قدیمی نشان میدهد و بیمار مدتی میبیند که با مدت واقعی نوبتش نمیخواند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
|
||||||
|
معیار کامل با همهٔ حالتهای مرزی در `task.md` همان تسک است. خلاصهٔ اجباری:
|
||||||
|
|
||||||
|
- ✅ **موفق:** مرحلهٔ انتخاب سرویس در دارکمود درست رندر میشود — هیچ متن تیره روی زمینهٔ
|
||||||
|
تیره، هیچ کارت سفید.
|
||||||
|
- ✅ **موفق:** مدت نمایشدادهشده از `total_duration_minutes` پاسخ بکاند میآید؛ اگر بکاند
|
||||||
|
عدد متفاوتی بدهد، UI همان را نشان میدهد.
|
||||||
|
- ✅ **موفق:** پزشکی با دو شیفت (صبح ۹-۱۳، عصر ۱۶-۲۰) در حالت سرویسی → **دو تب** زمانی با
|
||||||
|
برچسب واقعی هر شیفت.
|
||||||
|
- ✅ **موفق:** کارت نوبت در پنل کاربر نام سرویسها و مدت را نشان میدهد.
|
||||||
|
- ✅ **موفق:** بیمار از پنل نوبت سرویسیاش را جابهجا میکند و **عددی وارد نمیکند** — مدت
|
||||||
|
را بکاند حساب میکند.
|
||||||
|
- ✅ **موفق (⛔ خط سرخ):** رزرو اسلاتی سرتاسر **بیتبهبیت مثل قبل** کار میکند و کارت نوبت
|
||||||
|
اسلاتی در پنل هیچ تغییری نمیکند.
|
||||||
|
- ❌ **خطا:** جابهجایی به زمان اشغالشده → **پیام فارسی خودِ بکاند** نمایش داده میشود
|
||||||
|
(نه «خطای نامشخص») و فهرست زمانها خودکار `refetch` میشود.
|
||||||
|
- ❌ **خطا:** انتخاب صفر سرویس → دکمهٔ ادامه غیرفعال با راهنمای فارسی.
|
||||||
|
- ⚠️ **مرزی:** پنل کاربری که **فقط** نوبت اسلاتی دارد → بدون هیچ تغییری رندر میشود
|
||||||
|
(شرط `&&` روی فیلدهای سرویسی).
|
||||||
|
- ⚠️ **مرزی:** محل سرویسی بدون سرویس `bookable` → پیام روشن + پیشنهاد محل دیگر اگر باشد.
|
||||||
|
- ⚠️ **مرزی:** `total_duration_minutes` در پاسخ نبود (بکاند قدیمی) → `fallbackSum` با
|
||||||
|
`console.warn`، نه صفحهٔ خالی.
|
||||||
|
- ⚠️ **مرزی:** نوبت سرویسیِ قدیمی بدون `service_items` → نام «—»، بدون کرش.
|
||||||
|
- ⚠️ **مرزی:** نوبت رزرو (`is_reserve`) در پنل → فقط سرویسها، بدون مدت (زمان ندارد).
|
||||||
|
- ⚠️ **مرزی:** نوبت ۹۰ دقیقهای با شکاف ۹۰ دقیقه → **یک** تب، نه تبهای تکعضوی
|
||||||
|
(آستانه = `max(60, durationMin)`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/appointment/service/index.js` | مرحلهٔ انتخاب سرویس — بازنویسی با توکن تم |
|
||||||
|
| `components/appointment/index.js` | ارکستراتور مراحل — تشخیص `booking_mode` per محل |
|
||||||
|
| `components/appointment/date/index.js` | مصرفکنندهٔ `adaptServiceSlots` |
|
||||||
|
| `components/appointment/detail/SubmitData.js` | ارسال `service_item_uuids` در ثبت |
|
||||||
|
| `lib/appointmentSlots.js` | `adaptSlots` (⛔ قفل) و `adaptServiceSlots` (بازنویسی) |
|
||||||
|
| `services/response.js` | افزودن `serviceReschedule` و `getServiceSlotsForReschedule` |
|
||||||
|
| `components/dashboard/userAccount/sidebars/turns/Card.js` | + نام سرویس و مدت |
|
||||||
|
| `.../turns/isTurnsDetails/DetailLg.js` · `DetailSm.js` | همان |
|
||||||
|
| `.../turns/isTurnsDetails/ButtonData.js` | + دکمهٔ جابهجایی |
|
||||||
|
| `.../turns/isTurnsDetails/modal/index.js` | مودال موجود — بازاستفاده |
|
||||||
|
| `mui/index.js` · `tailwind.config.js` · `app/globals.css` | منبع تم و توکن |
|
||||||
|
| `CLAUDE.md` | + بخش «حالتهای نوبتدهی» |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
### ۱. رنگهای hard-code — `components/appointment/service/index.js`
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب سرویس</h2>
|
||||||
|
|
||||||
|
{services.length === 0 ? (
|
||||||
|
<p className="text-[14px] text-[#7A7A7A]">
|
||||||
|
در حال حاضر سرویسی برای نوبتدهی آنلاین تعریف نشده است.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
…
|
||||||
|
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]"
|
||||||
|
}`}
|
||||||
|
```
|
||||||
|
|
||||||
|
چهار رنگ ثابت. سایت `darkMode: "class"` دارد و صفحات عمومی با `data-theme` تم عوض
|
||||||
|
میکنند (`app/Providers.js` با `next-themes`) — این کامپوننت در دارکمود میشکند، در حالی
|
||||||
|
که بقیهٔ مراحل رزرو نمیشکنند.
|
||||||
|
|
||||||
|
### ۲. محاسبهٔ موازی مدت — همان فایل
|
||||||
|
|
||||||
|
```js
|
||||||
|
const totalMinutes = services
|
||||||
|
.filter((s) => draft.includes(s.uuid))
|
||||||
|
.reduce((sum, s) => sum + (Number(s.duration_minutes) || 0), 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
بکاند همان عدد را در `total_duration_minutes` پاسخ `appointment-service-slots` میدهد.
|
||||||
|
دو منبع حقیقت.
|
||||||
|
|
||||||
|
### ۳. `adaptServiceSlots` — `lib/appointmentSlots.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
// حالت سرویسی: پاسخ appointment-service-slots فقط start_times دارد (همه کافی).
|
||||||
|
// آنها را در یک session قالببندی میکنیم تا مثل حالت اسلاتی رندر شوند.
|
||||||
|
export function adaptServiceSlots(slotsResponse) {
|
||||||
|
const starts =
|
||||||
|
slotsResponse?.data?.start_times ?? slotsResponse?.start_times ?? [];
|
||||||
|
if (!starts.length) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
start_time: starts[0].start_time,
|
||||||
|
end_time: starts[starts.length - 1].start_time,
|
||||||
|
label: "زمانهای خالی",
|
||||||
|
slots: starts.map((s) => ({ ...s, is_available: true })),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
سه مشکل: همه در یک تب · برچسب ثابت · `end_time` برابر **شروعِ** آخرین اسلات، نه پایان نوبت.
|
||||||
|
|
||||||
|
⛔ تابع `adaptSlots()` بالای همین فایل (حالت اسلاتی) **قفل است** و یک خط هم عوض نمیشود:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function adaptSlots(slotsResponse) {
|
||||||
|
const sessions = slotsResponse?.data?.sessions ?? slotsResponse?.sessions ?? [];
|
||||||
|
return sessions
|
||||||
|
.filter((session) => Array.isArray(session.slots) && session.slots.length > 0)
|
||||||
|
.map((session) => ({ … }));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۴ و ۵. پنل کاربر
|
||||||
|
|
||||||
|
`components/dashboard/userAccount/sidebars/turns/Card.js` و
|
||||||
|
`isTurnsDetails/DetailLg.js`/`DetailSm.js` هیچ ارجاعی به `service` یا مدت ندارند.
|
||||||
|
`ButtonData.js` هیچ مسیر جابهجایی ندارد.
|
||||||
|
|
||||||
|
### تشخیص حالت — `components/appointment/index.js:125` (درست، دستنخورده بماند)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// روش نوبتدهی و سرویسها per-location هستند: یک پزشک میتواند در مطب شخصی
|
||||||
|
// اسلاتی و در کلینیک سرویسی باشد.
|
||||||
|
const bookingMode = selectedLocation?.booking_mode === "service" ? "service" : "slot";
|
||||||
|
const bookingServices = selectedLocation?.services ?? [];
|
||||||
|
```
|
||||||
|
|
||||||
|
و `changeLocation()` که انتخابهای وابسته را باطل میکند — این رفتار درست است و حفظ میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
`todo` را از ردیفهای `checklist.md` همان تسک بساز (بخشهای ۰ تا ۸)، نه از این هشت وظیفه.
|
||||||
|
هر ردیف چکلیست را همان لحظه `⏳ → 🔄 → ✅` کن.
|
||||||
|
|
||||||
|
### ۱. پیشبررسی قرارداد API — پیش از هر خط کد
|
||||||
|
|
||||||
|
سه چیز را تأیید کن. اگر فیلدی نیست، **کد نزن**؛ به تسک ۰۰ برگردان.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=<access_token یک کاربر تست>
|
||||||
|
|
||||||
|
# ۱. appointments/user چه فیلدهایی دارد؟
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
https://clinic-pro.ddev.site/api/v1/appointments/user | jq '.data[0] | keys'
|
||||||
|
# باید service_items و service_total_minutes داشته باشد
|
||||||
|
|
||||||
|
# ۲. هر start_times[i] فیلد end_time دارد؟
|
||||||
|
curl -s "https://clinic-pro.ddev.site/api/v1/appointment-service-slots?doctor_uuid=…&date=…&service_item_uuids[]=…" \
|
||||||
|
| jq '.data.start_times[0], .data.total_duration_minutes, .data.buffer_minutes'
|
||||||
|
|
||||||
|
# ۳. exclude_appointment_uuid کار میکند؟
|
||||||
|
curl -s "…&exclude_appointment_uuid=<uuid نوبت موجود>" | jq '.data.start_times | length'
|
||||||
|
```
|
||||||
|
|
||||||
|
**نحوه تست:** خروجی واقعی هر سه دستور را در گزارش بگذار. ردیفهای ۱.۱ تا ۱.۵ چکلیست
|
||||||
|
با همینها ✅ میشوند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۲. بازنویسی `service/index.js` با توکن تم
|
||||||
|
|
||||||
|
**اول رنگهای همسایه را ببین** — هدف این است که این کامپوننت از بقیهٔ مراحل رزرو قابل
|
||||||
|
تشخیص نباشد:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "className" components/appointment/location/index.js | head -30
|
||||||
|
grep -n "className" components/appointment/date/index.js | head -30
|
||||||
|
```
|
||||||
|
|
||||||
|
بعد همان الگو را اعمال کن. ساختار DOM و رفتار `toggle` **عوض نمیشود** — فقط منبع رنگ.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// نمونه؛ نام دقیق کلاس را از tailwind.config.js و mui/index.js همین پروژه بردار
|
||||||
|
<h2 className="text-base font-bold text-foreground mb-4">۱. انتخاب سرویس</h2>
|
||||||
|
|
||||||
|
className={active
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border bg-surface hover:border-primary"}
|
||||||
|
```
|
||||||
|
|
||||||
|
اگر پروژه توکن معادل ندارد، **توکن جدید نساز** — همان کاری را بکن که `location/index.js`
|
||||||
|
میکند. اگر دارکمود در آن هم شکسته است، این یک مسئلهٔ جداست: ردیف ⚠️ در چکلیست ثبت کن
|
||||||
|
و **دامنه را گسترش نده**. صفحهٔ رزرو در این تسک بازطراحی نمیشود.
|
||||||
|
|
||||||
|
**نحوه تست:** سناریوهای ۶.۱ و ۶.۲ چکلیست — رزرو سرویسی کامل، یک بار در لایت و یک بار
|
||||||
|
در دارک (`data-theme`). اسکرینشات هر دو حالت در گزارش.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۳. حذف محاسبهٔ موازی مدت
|
||||||
|
|
||||||
|
مدت از `total_duration_minutes` میآید. مسئلهٔ ترتیبی: مرحلهٔ انتخاب سرویس **پیش از**
|
||||||
|
انتخاب روز است و آن endpoint تاریخ میخواهد. راهحل انتخابشده (دلیل در
|
||||||
|
`architecture.md` بخش ۲): فراخوانی با تاریخ امروز فقط برای گرفتن مدت — پاسخ ممکن است
|
||||||
|
`start_times` خالی داشته باشد ولی `total_duration_minutes` میآید.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { data } = useServiceDuration(doctorUuid, clinicUuid, draft); // hook جدید
|
||||||
|
|
||||||
|
const minutes = data?.total_duration_minutes ?? (() => {
|
||||||
|
console.warn('[booking] total_duration_minutes missing — falling back to client sum');
|
||||||
|
return fallbackSum(draft, services);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// پیش از انتخاب روز
|
||||||
|
<span>مدت تقریبی: {minutes} دقیقه</span>
|
||||||
|
// پس از انتخاب روز — از همان پاسخ appointment-service-slots
|
||||||
|
<span>مدت نوبت: {minutes} دقیقه</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
`fallbackSum` موقت است و فقط برای بکاند قدیمی. حذفش را در چکلیست بهعنوان ردیف `⏳` با
|
||||||
|
دلیل و تسک مقصد ثبت کن.
|
||||||
|
|
||||||
|
**نحوه تست:** دو سرویس انتخاب کن و عدد UI را با
|
||||||
|
`curl … | jq '.data.total_duration_minutes'` مقایسه کن — باید یکی باشند. بعد یک بار
|
||||||
|
`total_duration_minutes` را از پاسخ حذف کن (mock) و ببین `console.warn` میزند و صفحه
|
||||||
|
خالی نمیشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۴. `adaptServiceSlots` شیفتآگاه
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function adaptServiceSlots(slotsResponse) {
|
||||||
|
const payload = slotsResponse?.data ?? slotsResponse ?? {};
|
||||||
|
const starts = payload.start_times ?? [];
|
||||||
|
if (!starts.length) return [];
|
||||||
|
|
||||||
|
const durationMin = Number(payload.total_duration_minutes) || 0;
|
||||||
|
// آستانه هرگز کمتر از مدت نوبت — وگرنه نوبت بلند به تبهای تکعضوی میشکند
|
||||||
|
const threshold = Math.max(60, durationMin);
|
||||||
|
|
||||||
|
const groups = [];
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
for (const s of starts) {
|
||||||
|
const gapMin = current
|
||||||
|
? (s.start - current.slots[current.slots.length - 1].start) / 60
|
||||||
|
: Infinity;
|
||||||
|
if (!current || gapMin > threshold) {
|
||||||
|
current = { slots: [] };
|
||||||
|
groups.push(current);
|
||||||
|
}
|
||||||
|
current.slots.push({ ...s, is_available: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.map((g) => {
|
||||||
|
const first = g.slots[0];
|
||||||
|
const last = g.slots[g.slots.length - 1];
|
||||||
|
const endTime = last.end_time ?? addMinutes(last.start_time, durationMin);
|
||||||
|
return {
|
||||||
|
start_time: first.start_time,
|
||||||
|
end_time: endTime,
|
||||||
|
label: `${first.start_time} - ${endTime}`, // همان قالب حالت اسلاتی
|
||||||
|
slots: g.slots,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
کامنت بنویس که گروهبندی **هیوریستیک** است و راه دقیقش endpoint تسک ۰۶ است — تصمیم و
|
||||||
|
دلیل رد گزینهٔ «گروهبندی از بکاند» در `architecture.md` بخش ۳ ثبت شده (تغییر قرارداد
|
||||||
|
endpoint که سه کلاینت مصرفش میکنند).
|
||||||
|
|
||||||
|
⛔ `adaptSlots()` را لمس نکن.
|
||||||
|
|
||||||
|
**نحوه تست:** `adaptServiceSlots` تابع خالص است — بهترین کاندید تست واحد. پنج حالت:
|
||||||
|
|
||||||
|
```
|
||||||
|
یک شیفت → یک گروه
|
||||||
|
دو شیفت با شکاف ۳ ساعت → دو گروه با برچسب درست
|
||||||
|
نوبت ۹۰ دقیقهای با شکاف ۹۰ دقیقه → یک گروه
|
||||||
|
start_times خالی → []
|
||||||
|
end_time از پاسخ میآید، نه محاسبه
|
||||||
|
```
|
||||||
|
|
||||||
|
و سناریوی دستی ۶.۴: پزشک دو-شیفته در حالت سرویسی → دو تب در UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۵. سرویس و مدت در پنل کاربر
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Card.js — شرط && اجباری است
|
||||||
|
{turn.service_items?.length > 0 && (
|
||||||
|
<span className="…">{turn.service_items.map((s) => s.name).join("، ")}</span>
|
||||||
|
)}
|
||||||
|
{turn.service_total_minutes && !turn.is_reserve && (
|
||||||
|
<span className="…">{turn.service_total_minutes} دقیقه</span>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
بدون `&&`، کارت **همهٔ** نوبتهای اسلاتی کرش میکند — یعنی کل پنل کاربر میشکند، نه یک
|
||||||
|
خط. نوبت رزرو مدت نمیگیرد چون زمان ندارد. نوبت سرویسیِ قدیمی بدون `service_items` →
|
||||||
|
نام «—».
|
||||||
|
|
||||||
|
همین را در `DetailLg.js` و `DetailSm.js` هم اعمال کن.
|
||||||
|
|
||||||
|
**نحوه تست:** سناریوهای ۶.۵ تا ۶.۷ — پنل کاربری که **فقط** نوبت اسلاتی دارد باید بدون
|
||||||
|
تغییر رندر شود؛ پنل با نوبت سرویسی سرویس و مدت را نشان دهد؛ هر دو در دارکمود پنل
|
||||||
|
(`class`، نه `data-theme` — دو مکانیزم متفاوتاند).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۶. جابهجایی سرویسآگاه از پنل
|
||||||
|
|
||||||
|
```js
|
||||||
|
// services/response.js
|
||||||
|
serviceReschedule: (uuid, body) =>
|
||||||
|
request.post(`api/v1/appointment/${uuid}/service-reschedule`, body, { requireAuth: true }),
|
||||||
|
|
||||||
|
getServiceSlotsForReschedule: (doctor_uuid, date, service_uuids, exclude_uuid, clinic_uuid) =>
|
||||||
|
request.get(
|
||||||
|
`api/v1/appointment-service-slots?doctor_uuid=${doctor_uuid}&date=${date}` +
|
||||||
|
service_uuids.map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`).join("") +
|
||||||
|
`&exclude_appointment_uuid=${exclude_uuid}` + clinicQuery(clinic_uuid),
|
||||||
|
{ requireAuth: true }
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
`ButtonData.js` دکمهٔ «جابهجایی» میگیرد که مودال موجود
|
||||||
|
(`isTurnsDetails/modal/index.js`) را با `components/appointment/date/` باز میکند —
|
||||||
|
**انتخابگر زمان جدید نساز**؛ آن کامپوننت هر دو حالت را از قبل میشناسد.
|
||||||
|
|
||||||
|
بیمار **مدت وارد نمیکند**: `service-reschedule` فقط `start` میگیرد.
|
||||||
|
|
||||||
|
خطا را با پیام خودِ بکاند نشان بده:
|
||||||
|
|
||||||
|
```js
|
||||||
|
catch (err) {
|
||||||
|
const msg = err?.response?.data?.errors?.[0]?.message ?? 'خطایی رخ داد';
|
||||||
|
toast.error(msg);
|
||||||
|
refetchSlots(); // بعد از خطای تداخل اجباری
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ERR_SLOT_TAKEN` پیام فارسی دقیق دارد؛ «خطای نامشخص» یعنی بیمار همان دکمه را ده بار میزند.
|
||||||
|
|
||||||
|
**نحوه تست:** سناریوهای ۶.۸ و ۶.۹ — جابهجایی موفق (مدت حفظ میشود، بیمار عددی وارد
|
||||||
|
نکرده) و جابهجایی به زمانی که همزمان توسط شخص دیگری گرفته شده (پیام فارسی + `refetch`).
|
||||||
|
برای دومی، یک نوبت از پنل ادمین روی همان زمان بساز و بعد دکمه را بزن.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۷. بهروزرسانی `checklist.md` — دو مخزن جدا
|
||||||
|
|
||||||
|
⚠️ **این تسک دو مخزن git را لمس میکند:**
|
||||||
|
|
||||||
|
| مخزن | چه چیزی |
|
||||||
|
|---|---|
|
||||||
|
| `nobat724_front` | همهٔ تغییرات کد |
|
||||||
|
| `clinicpro` | فقط `docs/new_feture/taskes/task-00b-nobat724-service-mode/checklist.md` |
|
||||||
|
|
||||||
|
پس **دو commit** لازم است. فراموش کردن دومی یعنی راهبر تسک بعدی فکر میکند ۰۰ب تمام
|
||||||
|
نشده و دوباره تحویلش میدهد.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ۱. کد سایت
|
||||||
|
cd nobat724_front
|
||||||
|
git checkout -b feat/booking-service-mode-frontend # اگر روی main هستی
|
||||||
|
git add -A
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(booking): align public site with service booking mode
|
||||||
|
|
||||||
|
Theme tokens in service picker, backend-driven duration, shift-aware
|
||||||
|
slot grouping, service/duration in user panel, service-aware reschedule.
|
||||||
|
Slot-mode path untouched.
|
||||||
|
|
||||||
|
Task: clinicpro/docs/new_feture/taskes/task-00b-nobat724-service-mode/
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
|
||||||
|
# ۲. چکلیست در مخزن clinicpro
|
||||||
|
cd ../clinicpro
|
||||||
|
git add docs/new_feture/taskes/task-00b-nobat724-service-mode/checklist.md
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
docs(booking): mark task 00b checklist complete
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**نحوه تست:** `git log --oneline -1` در هر دو مخزن، و
|
||||||
|
`grep -c '| ⏳ |' clinicpro/docs/new_feture/taskes/task-00b-nobat724-service-mode/checklist.md`
|
||||||
|
که باید ۰ بدهد (یا فقط ردیفهای `⏳` با یادداشت دلیل).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۸. مستندات و گزارش پایانی
|
||||||
|
|
||||||
|
```
|
||||||
|
nobat724_front/CLAUDE.md ← بخش «حالتهای نوبتدهی»: slot و service،
|
||||||
|
per محل تعیین میشوند، adaptSlots/adaptServiceSlots
|
||||||
|
نقطهٔ تفکیکاند
|
||||||
|
clinicpro/docs/api/appointment.md ← یادداشت: گروهبندی شیفت در حالت سرویسی
|
||||||
|
هیوریستیک سمت فرانت است؛ راه دقیقش endpoint تسک ۰۶
|
||||||
|
```
|
||||||
|
|
||||||
|
گزارش پایانی:
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ تسک ۰۰ب تمام شد
|
||||||
|
|
||||||
|
پیادهسازی
|
||||||
|
• <فایلها با مسیر>
|
||||||
|
|
||||||
|
تست
|
||||||
|
• npm run build : بدون خطا
|
||||||
|
• npm run lint : بدون خطای جدید
|
||||||
|
• تست واحد adaptServiceSlots: <N> حالت، سبز
|
||||||
|
• ۱۱ سناریوی دستی : <فهرست با نتیجه>
|
||||||
|
|
||||||
|
خط سرخ
|
||||||
|
• مسیر اسلاتی دستنخورده — adaptSlots عوض نشد؛ سناریوی ۶.۳ سرتاسر تست شد
|
||||||
|
|
||||||
|
کلاینت دیگر
|
||||||
|
• clinic-pro-tauri: <چه بررسی شد — قرارداد service_item تکی>
|
||||||
|
|
||||||
|
به تعویق افتاد
|
||||||
|
• fallbackSum — دلیل: تا deploy تسک ۰۰ · تسک مقصد: <XX>
|
||||||
|
|
||||||
|
commit سایت: <hash> · commit چکلیست: <hash>
|
||||||
|
|
||||||
|
────────────────────────────────
|
||||||
|
🎯 بعدی: تسک ۰۱ — شعبه و اتاق
|
||||||
|
اجرا کن: /run-prompt clinicpro/.claude/prompt/booking-engine-task-runner.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
### ⛔ خط سرخ — مسیر اسلاتی سایت
|
||||||
|
|
||||||
|
`_shared/red-lines.md` حاکم است. در این پروژه یعنی:
|
||||||
|
|
||||||
|
- `adaptSlots()` **یک خط هم** عوض نمیشود
|
||||||
|
- رندر تبهای شیفت در حالت اسلاتی دستنخورده
|
||||||
|
- کارت نوبت اسلاتی در پنل بدون تغییر (شرط `&&` روی فیلدهای سرویسی)
|
||||||
|
- سناریوی ۶.۳ (رزرو اسلاتی سرتاسر) **اجباری** است، نه اختیاری
|
||||||
|
|
||||||
|
اگر بعد از تغییرات، رزرو اسلاتی یک پیکسل هم فرق کرد، تغییر برمیگردد.
|
||||||
|
|
||||||
|
### دامنه را گسترش نده
|
||||||
|
|
||||||
|
فقط `service/index.js` که خودمان اضافه کردیم و از بقیهٔ مراحل منحرف است بازنویسی میشود.
|
||||||
|
اگر `location/index.js` یا `date/index.js` هم رنگ hard-code دارند و دارکمودشان شکسته
|
||||||
|
است، این یک مسئلهٔ جداست: ردیف `⚠️` در چکلیست ثبت کن و بگذار. بازطراحی کل صفحهٔ رزرو
|
||||||
|
در این تسک نیست.
|
||||||
|
|
||||||
|
### قواعد سایت که رعایت میشوند
|
||||||
|
|
||||||
|
از `_shared/ui-conventions.md` بخش `nobat724_front` و `CLAUDE.md` پروژه:
|
||||||
|
|
||||||
|
- تم MUI از `mui/index.js` با `direction: rtl` — **تم جدید نساز**
|
||||||
|
- Tailwind با `darkMode: "class"`؛ صفحات عمومی `data-theme`، پنل `class` — **هر دو تست شوند**
|
||||||
|
- **فونت فقط Vazir** از `app/globals.css` با `@font-face` — فونت دیگر اضافه نکن
|
||||||
|
- فراخوانی API از `services/response.js` → `request.*`؛ برای auth `{ requireAuth: true }`
|
||||||
|
تا کوکی `access_token` بهصورت Bearer ضمیمه شود
|
||||||
|
- داده server-side با `lib/req.js` → `fetchReq(url)`
|
||||||
|
- کامپوننتهای موجود `components/appointment/*` توسعه داده میشوند، مسیر موازی نه
|
||||||
|
- تاریخ شمسی با `jalali-moment`
|
||||||
|
- RTL — `ms-*`/`me-*` نه `ml-*`/`mr-*`
|
||||||
|
- هر صفحهای که دست خورد، `generateMetadata` و `await params` سالم بماند
|
||||||
|
- slug پزشک/کلینیک = `uuid`
|
||||||
|
- شهر از subdomain: server-side `lib/getStateInfo.js` · client-side `useProvince()`
|
||||||
|
|
||||||
|
### وابستگی سخت به تسک ۰۰
|
||||||
|
|
||||||
|
سه endpoint و دو ستون این تسک را تسک ۰۰ میسازد. اگر
|
||||||
|
`clinicpro/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md` کامل ✅
|
||||||
|
نیست، **این تسک شروع نمیشود**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -c '| ⏳ |\|| 🔄 |' clinicpro/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md
|
||||||
|
# باید ۰ بدهد
|
||||||
|
```
|
||||||
|
|
||||||
|
اگر نداد، پیام بده و بایست:
|
||||||
|
`/run-prompt clinicpro/.claude/prompt/booking-engine-task-runner.md`
|
||||||
|
|
||||||
|
### cross-repo — بررسی دستی اجباری
|
||||||
|
|
||||||
|
`clinic-pro-tauri/src/service/response.js` هم مصرفکنندهٔ همان `/api/v1/...` است. این
|
||||||
|
تسک قرارداد بکاند را عوض نمیکند (فقط مصرف میکند)، ولی اگر تسک ۰۰ فیلدی را جابهجا
|
||||||
|
کرده باشد، اپ دسکتاپ هم متأثر است. بررسی کن که `service_item` تکی هنوز در پاسخ هست و
|
||||||
|
گزارش بده — «بررسی شد» بیارزش است، نام فایل و فیلد را بنویس (guidelines §۳).
|
||||||
|
|
||||||
|
### تست خودکار محدود است
|
||||||
|
|
||||||
|
پروژه تست خودکار کمی دارد، پس **یازده سناریوی دستی بخش ۶ چکلیست اجباریاند**، نه
|
||||||
|
توصیه. هر ده مورد اول روی موبایل هم تکرار میشوند (سناریو ۶.۱۰) — بدون اسکرول افقی.
|
||||||
|
|
||||||
|
`adaptServiceSlots` تابع خالص است و بهترین کاندید تست واحد؛ اگر پروژه runner تست ندارد،
|
||||||
|
یک فایل ساده در `tests/appointmentSlots.test.js` بساز و در چکلیست ثبت کن.
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# نمایش محلهای نوبتدهی فقط بر اساس برنامهٔ همان روز
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
پرامپت همتای backend که **باید اول اجرا شود**:
|
||||||
|
`clinicpro/.claude/prompt/fix-booking-context-slots-and-phantom-locations.md`
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
صفحهٔ رزرو حالا محل نوبتدهی (مطب شخصی / کلینیک) را از
|
||||||
|
`GET /api/v1/appointment-booking-locations/{doctorUuid}` میگیرد و کاربر یکی را انتخاب میکند.
|
||||||
|
|
||||||
|
اما این فهرست **وضعیت واقعی رزرو در یک روز مشخص** را نشان نمیدهد. برای «دکتر تست»
|
||||||
|
(`bcabb3a8-cae3-45ec-876c-548f9c1e1569`) گزینهٔ «مطب شخصی» نمایش داده میشود، در حالی که در
|
||||||
|
دیتابیس این پزشک **هیچ آدرس شخصی ثبتشدهای ندارد** و شیفت برنامهٔ شخصیاش هم
|
||||||
|
`location_id = NULL` است. یعنی محلی که اصلاً قابل رزرو نیست، به بیمار پیشنهاد میشود.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
قاعدهٔ درست نمایش یک محل:
|
||||||
|
|
||||||
|
1. برای آن محل حداقل یک **آدرس ثبتشده** وجود داشته باشد، **و**
|
||||||
|
2. در برنامهٔ کاری، همان آدرس برای شیفتهای آن محل **انتخاب شده** باشد، **و**
|
||||||
|
3. برای **روز انتخابشده** حداقل یک اسلات آزاد داشته باشد.
|
||||||
|
|
||||||
|
اگر هر کدام برقرار نباشد، آن محل نباید بهعنوان گزینهٔ قابل انتخاب نمایش داده شود — نه در
|
||||||
|
`/appointment/[doctorId]` و نه در پروفایل پزشک `/doctor/[slug]`.
|
||||||
|
|
||||||
|
شرطهای ۱ و ۲ در backend اعمال میشوند (پرامپت همتا). این پرامپت شرط ۳ و مصرف درست فهرست را
|
||||||
|
پوشش میدهد.
|
||||||
|
|
||||||
|
## قرارداد API بعد از تغییر backend
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/appointment-booking-locations/{doctorUuid}
|
||||||
|
GET /api/v1/appointment-booking-locations/{doctorUuid}?date=YYYY-MM-DD
|
||||||
|
```
|
||||||
|
|
||||||
|
- بدون `date`: فقط محلهای **معتبر** (آدرس دارند و شیفت روی آدرس دارند). محل بدون آدرس دیگر
|
||||||
|
اصلاً برنمیگردد.
|
||||||
|
- با `date`: هر آیتم فیلد `available_on_date` (بولین) میگیرد و پاسخ `date` را echo میکند.
|
||||||
|
- `opening_hours` هر آیتم حالا `day_index` و `location_id` هم دارد.
|
||||||
|
|
||||||
|
بقیهٔ قرارداد بدون تغییر: مرتب بر اساس `next_available_at` صعودی، پاسخ double-nested
|
||||||
|
(`json.data.data`).
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `services/response.js` | `getBookingLocations` — باید `date` بگیرد |
|
||||||
|
| `components/appointment/index.js` | `AppointmentPage` — نگهدارندهٔ `bookingLocations` و `selectedLocation` |
|
||||||
|
| `components/appointment/Container.js` | مرحلهٔ انتخاب محل / سرویس / تاریخ |
|
||||||
|
| `components/appointment/location/LocationSelect.js` | کارتهای انتخاب محل |
|
||||||
|
| `components/appointment/date/index.js` | مرحلهٔ تاریخ + دکمهٔ «تغییر محل» |
|
||||||
|
| `app/component/date/datePicker/index.js` | تقویم ماه (`getMonthAvailability`) |
|
||||||
|
| `app/doctor/[slug]/page.js` | پروفایل پزشک + JSON-LD |
|
||||||
|
| `components/doctor/detailDoctor/cards/locations/index.js` | کارت «موقعیت مکانی» در پروفایل |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
### فهرست محلها وابسته به روز نیست
|
||||||
|
|
||||||
|
`components/appointment/index.js` — یکبار در mount گرفته میشود و تا آخر ثابت میماند:
|
||||||
|
|
||||||
|
```js
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doctor?.uuid) return;
|
||||||
|
request
|
||||||
|
.getBookingLocations(doctor.uuid)
|
||||||
|
.then((res) => {
|
||||||
|
const d = res?.data?.data ?? res?.data ?? {};
|
||||||
|
const list = Array.isArray(d.booking_locations) ? d.booking_locations : [];
|
||||||
|
setBookingLocations(list);
|
||||||
|
...
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`selectedDate` بعداً در همین کامپوننت ست میشود ولی هیچوقت به این فراخوانی برنمیگردد.
|
||||||
|
|
||||||
|
### پروفایل پزشک همهٔ آدرسها را نشان میدهد
|
||||||
|
|
||||||
|
`app/doctor/[slug]/page.js` — `workLocation` در JSON-LD و کارت «موقعیت مکانی» از
|
||||||
|
`getDoctorAddresses(doctor.id)` میآیند که **همهٔ** آدرسها را برمیگرداند، مستقل از اینکه در
|
||||||
|
برنامهٔ نوبتدهی استفاده شده باشند یا نه.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. `date` در لایهٔ سرویس
|
||||||
|
|
||||||
|
`services/response.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
getBookingLocations: (doctor_uuid, date = null) =>
|
||||||
|
api.get(`api/v1/appointment-booking-locations/${doctor_uuid}`, {
|
||||||
|
params: date ? { date } : {},
|
||||||
|
...removeTokenHead,
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. واکشی دوباره با تغییر روز
|
||||||
|
|
||||||
|
در `components/appointment/index.js`:
|
||||||
|
|
||||||
|
- فهرست اولیه بدون `date` گرفته شود (برای مرحلهٔ انتخاب محل، قبل از انتخاب روز).
|
||||||
|
- بعد از انتخاب روز، دوباره با `date` گرفته شود و `available_on_date` روی کارتها اعمال شود.
|
||||||
|
|
||||||
|
`selectedDate` در این کامپوننت **timestamp** است؛ برای پارامتر API باید به `YYYY-MM-DD` تبدیل شود
|
||||||
|
(از `moment-jalaali` که در پروژه هست استفاده کن، همان الگوی
|
||||||
|
`app/component/date/dateTime/index.js` که `moment.unix(date).format("YYYY-MM-DD")` میزند).
|
||||||
|
|
||||||
|
**حالت مرزی مهم:** اگر روزِ انتخابشده محلِ انتخابشده را غیرفعال کند
|
||||||
|
(`available_on_date === false`)، نباید بیصدا اسلات خالی نشان دهی. یا کاربر را به مرحلهٔ انتخاب
|
||||||
|
محل برگردان با پیام روشن، یا خودکار به اولین محلِ باز در آن روز سوییچ کن و این جابهجایی را
|
||||||
|
اطلاع بده. **بیصدا نگهداشتن محلِ بسته = صفحهٔ خالی بدون توضیح.**
|
||||||
|
|
||||||
|
### ۳. UI کارتهای محل
|
||||||
|
|
||||||
|
`components/appointment/location/LocationSelect.js`:
|
||||||
|
|
||||||
|
- وقتی `available_on_date === false`، کارت غیرفعال شود (`disabled`، `cursor-not-allowed`،
|
||||||
|
کمرنگ) با برچسب «در این روز نوبت ندارد».
|
||||||
|
- کارت غیرفعال قابل کلیک نباشد.
|
||||||
|
- اگر **هیچ** محلی در آن روز باز نبود، پیام روشن بده و کاربر را به انتخاب روز دیگر هدایت کن.
|
||||||
|
|
||||||
|
از تم و کامپوننتهای موجود استفاده کن (MUI v5 + Tailwind، RTL، فونت Vazir) — طراحی جدید نساز.
|
||||||
|
|
||||||
|
### ۴. تقویم ماه هم per-location است
|
||||||
|
|
||||||
|
`app/component/date/datePicker/index.js` الان `clinicUuid` میگیرد و `getMonthAvailability` را با
|
||||||
|
آن صدا میزند — این درست است و نیازی به تغییر ندارد. فقط مطمئن شو بعد از سوییچ محل، کش ماهها
|
||||||
|
پاک میشود (همان effect موجود روی `clinicUuid`).
|
||||||
|
|
||||||
|
### ۵. پروفایل پزشک — فقط محلهای قابل رزرو
|
||||||
|
|
||||||
|
در `app/doctor/[slug]/page.js`:
|
||||||
|
|
||||||
|
- `getBookingLocations` (بدون `date`) را server-side بگیر — همین الان برای `availableService` و
|
||||||
|
`openingHoursSpecification` گرفته میشود.
|
||||||
|
- کارت «موقعیت مکانی» (`components/doctor/detailDoctor/cards/locations/`) و `workLocation` در
|
||||||
|
JSON-LD باید **فقط** آدرسهایی را نشان دهند که `location_uuid` آنها در `booking_locations`
|
||||||
|
آمده است.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const bookableUuids = new Set(
|
||||||
|
bookingLocations.map((l) => l.location_uuid).filter(Boolean)
|
||||||
|
);
|
||||||
|
const bookableAddresses = addresses.filter((a) => bookableUuids.has(a.uuid));
|
||||||
|
```
|
||||||
|
|
||||||
|
- **تصمیم لازم:** آدرسی که ثبت شده ولی در هیچ برنامهای استفاده نشده، اطلاعات واقعی مطب است و
|
||||||
|
حذف کاملش از پروفایل ممکن است خواسته نباشد. پیشنهاد: در کارت «موقعیت مکانی» نمایش داده شود
|
||||||
|
ولی بدون دکمهٔ «دریافت نوبت»، و در JSON-LD **نیاید** (چون schema.org آن را قابل مراجعه اعلام
|
||||||
|
میکند). اگر تصمیم دیگری گرفتی، در PR بنویس.
|
||||||
|
|
||||||
|
### ۶. لینک CTA
|
||||||
|
|
||||||
|
`components/doctor/appointmentList/index.js` — اگر پروفایل محل مشخصی را برجسته کرد، CTA همان را
|
||||||
|
حمل کند: `/appointment/${doctorSlug}?clinic_uuid=${clinicUuid}`. اگر محلی برجسته نشده، پارامتر
|
||||||
|
ندهد تا صفحهٔ رزرو خودش انتخابگر را نشان دهد (رفتار فعلی، درست است).
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **backend اول.** تا وقتی فیلتر سمت سرور اعمال نشده، «مطب شخصی» جعلی همچنان برمیگردد و کار
|
||||||
|
فرانت قابل تست نیست.
|
||||||
|
- پاسخ double-nested: `json?.data?.data ?? json?.data`.
|
||||||
|
- endpoint عمومی است و `removeTokenHead` میگیرد؛ برای رزرو نهایی `{ requireAuth: true }`.
|
||||||
|
- تاریخها شمسی با `jalali-moment` / `moment-jalaali`؛ رشتههای جدید فارسی؛ RTL.
|
||||||
|
- slug پزشک = `uuid`.
|
||||||
|
- هر صفحه `generateMetadata` صادر کند و `params` همیشه `await` شود.
|
||||||
|
- تست دستی: «دکتر تست» (`bcabb3a8-cae3-45ec-876c-548f9c1e1569`) — بعد از فیلتر backend نباید هیچ
|
||||||
|
گزینهٔ «مطب شخصی» ببیند، فقط کلینیک «علی بهروزی». روزی که کلینیک شیفت ندارد هم باید محل را
|
||||||
|
غیرفعال نشان دهد.
|
||||||
|
- بعد از تغییرات: `npm run lint` و `npm run build` هر دو سبز.
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
# انتخاب محل نوبتدهی (مطب شخصی / کلینیک) در جریان رزرو
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — پرامپت همتای backend که **قبلاً اجرا شده** است:
|
||||||
|
`clinicpro/.claude/prompt/context-separation-clinic-vs-doctor-booking.md`
|
||||||
|
|
||||||
|
قرارداد API از سمت backend آماده است؛ این پرامپت فقط سمت مصرفکننده را مینویسد.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
تا پیش از این، هر پزشک در کل سیستم **یک** برنامهٔ نوبتدهی داشت (`weekly_schedules` با
|
||||||
|
`UNIQUE(doctor_id)`). حالا برنامه per-context است: یک برنامهٔ مطب شخصی + یکی به ازای هر کلینیکی
|
||||||
|
که پزشک در آن عضو است. ستون `clinic_id` روی `weekly_schedules`، `date_overrides` و `holidays`
|
||||||
|
اضافه شده (`NULL` = مطب شخصی).
|
||||||
|
|
||||||
|
سرویسها هم polymorphicاند و بین این دو محیط **هرگز** مشترک نمیشوند: سرویسهای کلینیک فقط در
|
||||||
|
نوبتدهی کلینیک و سرویسهای شخصی فقط در مطب شخصی. قیمت و مدت سرویس بین محلها متفاوت است.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
**این یک شکست خاموش است، نه یک قابلیت جدید.**
|
||||||
|
|
||||||
|
همهٔ endpointهای رزرو حالا پارامتر اختیاری `clinic_uuid` میگیرند و **نبودِ آن یعنی «مطب شخصی»** —
|
||||||
|
نه «هر محلی که پیدا شد». سایت الان هیچجا `clinic_uuid` نمیفرستد، پس:
|
||||||
|
|
||||||
|
- برای پزشکی که فقط در کلینیک کار میکند، سایت **هیچ نوبتی نشان نمیدهد** (برنامهٔ شخصی ندارد).
|
||||||
|
- برای پزشکی که هر دو را دارد، سایت فقط ظرفیت مطب شخصی را نشان میدهد و بیمار بدون اطلاع نوبت
|
||||||
|
را در محل اشتباه رزرو میکند.
|
||||||
|
- در حالت سرویسی، `POST /api/v1/appointment` با سرویسی که به آن محل تعلق ندارد `422` میگیرد.
|
||||||
|
|
||||||
|
هدف: بیمار **محل** را آگاهانه انتخاب کند و آن انتخاب تا لحظهٔ ثبت نوبت حمل شود.
|
||||||
|
|
||||||
|
## قرارداد API (آمادهٔ مصرف)
|
||||||
|
|
||||||
|
### endpoint جدید
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/appointment-booking-locations/{doctorUuid} (عمومی، بدون auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"doctor_uuid": "550e8400-…",
|
||||||
|
"booking_locations": [
|
||||||
|
{
|
||||||
|
"location_uuid": "0f0b…",
|
||||||
|
"type": "personal",
|
||||||
|
"title": "مطب شخصی",
|
||||||
|
"address": "یزد، خیابان …",
|
||||||
|
"clinic_uuid": null,
|
||||||
|
"booking_mode": "slot",
|
||||||
|
"buffer_minutes": 0,
|
||||||
|
"services": [],
|
||||||
|
"next_available_at": 1755000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location_uuid": "7c21…",
|
||||||
|
"type": "clinic",
|
||||||
|
"title": "کلینیک علی بهروزی",
|
||||||
|
"address": "یزد، بلوار …",
|
||||||
|
"clinic_uuid": "41e325c4-e825-4067-8438-5d828ecaee09",
|
||||||
|
"booking_mode": "service",
|
||||||
|
"buffer_minutes": 10,
|
||||||
|
"services": [
|
||||||
|
{ "uuid": "…", "name": "ویزیت", "duration_minutes": 20, "price_rials": 500000,
|
||||||
|
"service_section": { "uuid": "…", "name": "عمومی" } }
|
||||||
|
],
|
||||||
|
"next_available_at": 1754900000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
نکات قرارداد:
|
||||||
|
|
||||||
|
- آرایه **از قبل مرتب است** بر اساس `next_available_at` صعودی؛ محلهای بدون ظرفیت آخر میآیند.
|
||||||
|
پس `booking_locations[0]` پیشفرضِ درست است — دوباره مرتب نکن.
|
||||||
|
- `next_available_at` میتواند `null` باشد (تا ۳۰ روز آینده ظرفیتی نیست).
|
||||||
|
- `location_uuid` میتواند `null` باشد (آن محیط هنوز آدرس ثبتشده ندارد) — برای انتخاب از
|
||||||
|
`clinic_uuid` استفاده کن، نه `location_uuid`.
|
||||||
|
- `booking_mode` **per-location** است: یک پزشک میتواند در مطب شخصی اسلاتی و در کلینیک سرویسی باشد.
|
||||||
|
- `services` فقط در حالت `service` پُر است.
|
||||||
|
- پاسخ double-nested است (`json.data.data`) مثل بقیهٔ endpointها.
|
||||||
|
|
||||||
|
### پارامتر جدید روی endpointهای موجود
|
||||||
|
|
||||||
|
| endpoint | محل پارامتر |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/v1/appointment-slots` | query `clinic_uuid` |
|
||||||
|
| `GET /api/v1/appointment-service-slots` | query `clinic_uuid` |
|
||||||
|
| `GET /api/v1/appointment-booking-services/{doctorUuid}` | query `clinic_uuid` |
|
||||||
|
| `GET /api/v1/appointment-settings/month-availability/{doctorUuid}` | query `clinic_uuid` |
|
||||||
|
| `POST /api/v1/appointment` | body `clinic_uuid` |
|
||||||
|
|
||||||
|
هر چهار `GET` مقدار `clinic_uuid` را در پاسخ echo میکنند تا کلاینت بفهمد کدام محیط جواب داده.
|
||||||
|
اگر پزشک عضو آن کلینیک نباشد → `404 ERR_VALIDATION_002` («محل نوبتدهی یافت نشد»).
|
||||||
|
سرویسِ متعلق به محیط دیگر در `POST` → `422 ERR_VALIDATION_001`
|
||||||
|
(«سرویس انتخابشده به این محل نوبتدهی تعلق ندارد»).
|
||||||
|
|
||||||
|
مستندات کامل: `clinicpro/docs/api/appointment.md` → بخش «Booking context (2026-07)».
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|---|---|
|
||||||
|
| `services/response.js:52-72` | همهٔ فراخوانیهای رزرو |
|
||||||
|
| `components/appointment/index.js:45` | `AppointmentPage` — نگهدارندهٔ کل state رزرو |
|
||||||
|
| `components/appointment/Container.js:15-113` | مسیریابی مرحلهها (prop drilling) |
|
||||||
|
| `components/appointment/service/index.js:11` | `ServiceSelect` |
|
||||||
|
| `components/appointment/date/index.js` | wrapper مرحلهٔ تاریخ (`locateVisit` مرده) |
|
||||||
|
| `app/component/date/datePicker/index.js:24` | `DatePicker` — تقویم ماه + `getMonthAvailability` |
|
||||||
|
| `app/component/date/dateTime/index.js:11` | `DateTime` — اسلاتها + resolve آدرس |
|
||||||
|
| `app/component/date/dateTime/hours/index.js:17-26` | کارت «آدرس مطب:» |
|
||||||
|
| `components/appointment/detail/SubmitData.js:138-160` | payload نهایی `postAppointment` |
|
||||||
|
| `components/appointment/location/Address.js` | سایدبار آدرسها (نمایشی) |
|
||||||
|
| `app/doctor/[slug]/page.js:107-157` | JSON-LD صفحهٔ پزشک |
|
||||||
|
| `components/doctor/appointmentList/index.js:33` | CTA ورود به رزرو |
|
||||||
|
| `lib/appointmentSlots.js` | `adaptSlots` / `adaptServiceSlots` |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
### ۱. هیچ فراخوانیای محل را نمیفرستد
|
||||||
|
|
||||||
|
`services/response.js:52-72`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
getAppointmentSlots: (doctor_uuid, date) =>
|
||||||
|
api.get(
|
||||||
|
`api/v1/appointment-slots?doctor_uuid=${doctor_uuid}&date=${date}`,
|
||||||
|
removeTokenHead
|
||||||
|
),
|
||||||
|
getMonthAvailability: (doctor_uuid, year, month) =>
|
||||||
|
api.get(`api/v1/appointment-settings/month-availability/${doctor_uuid}`, {
|
||||||
|
params: { year, month },
|
||||||
|
...removeTokenHead,
|
||||||
|
}),
|
||||||
|
getBookingServices: (doctor_uuid) =>
|
||||||
|
api.get(`api/v1/appointment-booking-services/${doctor_uuid}`, removeTokenHead),
|
||||||
|
getServiceSlots: (doctor_uuid, date, serviceItemUuids = []) =>
|
||||||
|
api.get(
|
||||||
|
`api/v1/appointment-service-slots?doctor_uuid=${doctor_uuid}&date=${date}` +
|
||||||
|
serviceItemUuids
|
||||||
|
.map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`)
|
||||||
|
.join(""),
|
||||||
|
removeTokenHead
|
||||||
|
),
|
||||||
|
postAppointment: (data) => api.post(`api/v1/appointment`, data, { requireAuth: true }),
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. آدرس فقط نمایشی است و از `doctor.address` میآید
|
||||||
|
|
||||||
|
`app/component/date/dateTime/index.js:33-35` — تنها جایی که آدرس یک اسلات حل میشود:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const locationAddress = hour?.location_id
|
||||||
|
? doctor?.address?.find((a) => String(a.id) === String(hour.location_id))?.address ?? null
|
||||||
|
: null;
|
||||||
|
```
|
||||||
|
|
||||||
|
هیچ محلی قابل انتخاب نیست و هیچ id محلی در state رزرو ذخیره نمیشود.
|
||||||
|
|
||||||
|
### ۳. payload نهایی
|
||||||
|
|
||||||
|
`components/appointment/detail/SubmitData.js:138-158`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const appointmentPayload = {
|
||||||
|
doctor_uuid: doctor.uuid,
|
||||||
|
slot_start: selectedSlot.start,
|
||||||
|
slot_end: selectedSlot.end,
|
||||||
|
for_self: !isForAnother,
|
||||||
|
note: data?.patient_reason?.value || "",
|
||||||
|
patient_national_code: data?.national_code?.value || "",
|
||||||
|
patient_gender: data?.gender?.value?.id || data?.gender?.value || "",
|
||||||
|
city_id: cityId ?? null,
|
||||||
|
...(selectedServiceUuids?.length ? { service_item_uuids: selectedServiceUuids } : {}),
|
||||||
|
...(isForAnother ? { patient_name, patient_mobile, patient_reason } : {}),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۴. state مرده
|
||||||
|
|
||||||
|
`components/appointment/date/index.js:15` — `locateVisit` با `doctor?.multiwork` گیت شده، مقدار
|
||||||
|
اولیهاش `true` است و هیچ فرزندی `false` نمیکند. عملاً کد مرده است. **حذفش کن** و جای آن انتخاب
|
||||||
|
واقعی محل بنشان.
|
||||||
|
|
||||||
|
### ۵. JSON-LD بدون ساعات کاری
|
||||||
|
|
||||||
|
`app/doctor/[slug]/page.js:107-157` — `workLocation` وجود دارد ولی هیچ
|
||||||
|
`openingHoursSpecification` و هیچ `availableService` ندارد.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. لایهٔ سرویس — `services/response.js`
|
||||||
|
|
||||||
|
یک helper بساز تا `clinic_uuid` تکرار نشود:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const withClinic = (params, clinicUuid) =>
|
||||||
|
clinicUuid ? { ...params, clinic_uuid: clinicUuid } : params;
|
||||||
|
```
|
||||||
|
|
||||||
|
و امضاها را گسترش بده (پارامتر آخر، اختیاری، تا هیچ call site موجودی نشکند):
|
||||||
|
|
||||||
|
```js
|
||||||
|
getBookingLocations: (doctor_uuid) =>
|
||||||
|
api.get(`api/v1/appointment-booking-locations/${doctor_uuid}`, removeTokenHead),
|
||||||
|
|
||||||
|
getAppointmentSlots: (doctor_uuid, date, clinic_uuid = null) =>
|
||||||
|
api.get("api/v1/appointment-slots", {
|
||||||
|
params: withClinic({ doctor_uuid, date }, clinic_uuid),
|
||||||
|
...removeTokenHead,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getMonthAvailability: (doctor_uuid, year, month, clinic_uuid = null) =>
|
||||||
|
api.get(`api/v1/appointment-settings/month-availability/${doctor_uuid}`, {
|
||||||
|
params: withClinic({ year, month }, clinic_uuid),
|
||||||
|
...removeTokenHead,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getBookingServices: (doctor_uuid, clinic_uuid = null) =>
|
||||||
|
api.get(`api/v1/appointment-booking-services/${doctor_uuid}`, {
|
||||||
|
params: withClinic({}, clinic_uuid),
|
||||||
|
...removeTokenHead,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getServiceSlots: (doctor_uuid, date, serviceItemUuids = [], clinic_uuid = null) =>
|
||||||
|
api.get("api/v1/appointment-service-slots", {
|
||||||
|
params: withClinic({ doctor_uuid, date, "service_item_uuids[]": serviceItemUuids }, clinic_uuid),
|
||||||
|
...removeTokenHead,
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
**دقت:** `getAppointmentSlots` و `getServiceSlots` الان query را دستی میچسبانند. با انتقال به
|
||||||
|
`params`، سریالسازی آرایهٔ `service_item_uuids[]` توسط axios انجام میشود — بررسی کن خروجی همچنان
|
||||||
|
`?service_item_uuids[]=a&service_item_uuids[]=b` باشد (نه `service_item_uuids[0]=a`). اگر نبود،
|
||||||
|
`paramsSerializer` بده یا همان روش دستی را با افزودن `clinic_uuid` نگه دار.
|
||||||
|
|
||||||
|
### ۲. state انتخاب محل — `components/appointment/index.js`
|
||||||
|
|
||||||
|
`AppointmentPage` نگهدارندهٔ state است؛ محل هم همانجا بنشیند:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const [bookingLocations, setBookingLocations] = useState([]);
|
||||||
|
const [selectedLocation, setSelectedLocation] = useState(null); // یک آیتم از booking_locations
|
||||||
|
```
|
||||||
|
|
||||||
|
هنگام mount، `getBookingLocations(doctor.uuid)` را صدا بزن. سپس:
|
||||||
|
|
||||||
|
- اگر `?clinic_uuid=` یا `?location=` در URL بود → همان را انتخاب کن.
|
||||||
|
- وگرنه `booking_locations[0]` (آرایه از قبل بر اساس زودترین نوبت آزاد مرتب است).
|
||||||
|
- اگر آرایه خالی بود → پیام «برای این پزشک نوبتدهی آنلاین فعال نیست» و ادامه نده.
|
||||||
|
|
||||||
|
**بحرانی:** `booking_mode` و `services` را از `selectedLocation` بخوان، نه از فراخوانی جدای
|
||||||
|
`getBookingServices`. `booking_locations` هر دو را از قبل دارد و یک درخواست کمتر میزند. `state`های
|
||||||
|
`bookingMode` و `bookingServices` موجود (`index.js:46-67`) باید از `selectedLocation` مشتق شوند:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const bookingMode = selectedLocation?.booking_mode ?? "slot";
|
||||||
|
const bookingServices = selectedLocation?.services ?? [];
|
||||||
|
```
|
||||||
|
|
||||||
|
`getBookingServices` را فقط برای سازگاری در `response.js` نگه دار ولی از این جریان حذفش کن.
|
||||||
|
|
||||||
|
### ۳. تعویض محل باید state وابسته را پاک کند
|
||||||
|
|
||||||
|
وقتی کاربر محل را عوض میکند، اینها **باید** reset شوند وگرنه ترکیب نامعتبر ساخته میشود
|
||||||
|
(سرویس کلینیک A + اسلات کلینیک B → خطای ۴۲۲ در انتها):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const changeLocation = (loc) => {
|
||||||
|
setSelectedLocation(loc);
|
||||||
|
setSelectedServiceUuids([]);
|
||||||
|
setSelectedSlot(null);
|
||||||
|
setSelectedDate(null);
|
||||||
|
setStep(loc.booking_mode === "service" ? SERVICE_STEP : DATE_STEP);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۴. UI انتخاب محل
|
||||||
|
|
||||||
|
اگر `booking_locations.length > 1`، یک مرحله/کارت انتخاب محل **قبل از** انتخاب سرویس و تاریخ
|
||||||
|
نشان بده. اگر فقط یک محل بود، مرحله را نشان نده و مستقیم انتخابش کن.
|
||||||
|
|
||||||
|
هر کارت: `title`، `address`، و برچسب زودترین نوبت. برای `next_available_at` از `jalali-moment`
|
||||||
|
استفاده کن (همان الگوی بقیهٔ پروژه) و اگر `null` بود «فعلاً نوبت خالی ندارد» با ظاهر غیرفعال.
|
||||||
|
|
||||||
|
`components/appointment/location/Address.js` (سایدبار نمایشی) باید محل انتخابشده را برجسته کند.
|
||||||
|
`components/appointment/date/index.js` → `locateVisit` مرده را حذف کن.
|
||||||
|
|
||||||
|
**الزامی:** از کامپوننتها، تم، فونت (Vazir) و MUI v5 + Tailwind موجود استفاده کن — طراحی جدید
|
||||||
|
نساز. RTL رعایت شود.
|
||||||
|
|
||||||
|
### ۵. حمل محل تا لحظهٔ ثبت
|
||||||
|
|
||||||
|
`app/component/date/datePicker/index.js` و `app/component/date/dateTime/index.js` باید
|
||||||
|
`clinicUuid` را prop بگیرند و به فراخوانیها بدهند. `DatePicker` الان `doctorUuid` را از
|
||||||
|
`useParams().doctorId` میخواند (`index.js:25-26`) — `clinicUuid` را بهصورت prop بده، نه از URL.
|
||||||
|
|
||||||
|
`components/appointment/detail/SubmitData.js:138`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const appointmentPayload = {
|
||||||
|
doctor_uuid: doctor.uuid,
|
||||||
|
clinic_uuid: selectedLocation?.clinic_uuid ?? null,
|
||||||
|
slot_start: selectedSlot.start,
|
||||||
|
...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
آدرس نمایشی در `app/component/date/dateTime/index.js:33-35` را از `selectedLocation.address`
|
||||||
|
بگیر — نه از `doctor.address.find(...)`، چون `location_id` عددی است و در محیط کلینیک ممکن است در
|
||||||
|
`doctor.address` نباشد.
|
||||||
|
|
||||||
|
### ۶. لینکپذیری و SEO
|
||||||
|
|
||||||
|
- CTA در `components/doctor/appointmentList/index.js:33` باید محل را حمل کند وقتی صفحهٔ پزشک
|
||||||
|
محلی را برجسته کرده: `/appointment/${doctorSlug}?clinic_uuid=${clinicUuid}`.
|
||||||
|
- `app/appointment/[doctorId]/page.js` باید `searchParams` را بخواند (و `await` کند طبق App Router).
|
||||||
|
- `app/doctor/[slug]/page.js` — JSON-LD را با ساعات کاری هر محل غنی کن. `booking_locations` را
|
||||||
|
server-side بگیر (با `fetchReq` از `lib/req.js` و `revalidate` مشابه `getDoctorAddresses`) و برای
|
||||||
|
هر محل یک `MedicalClinic` با `openingHoursSpecification` بساز و به `workLocation` وصل کن.
|
||||||
|
در حالت سرویسی، `availableService` هم از `services` قابل ساخت است.
|
||||||
|
|
||||||
|
**انجام شد:** `booking_locations` حالا فیلد `opening_hours` دارد (شیفتهای فعال هفته با نام
|
||||||
|
انگلیسی روز)، پس `openingHoursSpecification` مستقیماً از همان ساخته میشود و به endpoint
|
||||||
|
جدیدی نیاز نیست.
|
||||||
|
- هر صفحه باید `generateMetadata` داشته باشد و `params` همیشه `await` شود.
|
||||||
|
|
||||||
|
### ۷. سازگاری و حالتهای مرزی
|
||||||
|
|
||||||
|
- پزشکی که فقط مطب شخصی دارد → یک آیتم با `type: "personal"`؛ UI نباید تغییری حس شود.
|
||||||
|
- پزشکی که فقط کلینیک دارد → قبلاً هیچ نوبتی نشان داده نمیشد؛ حالا باید کار کند. **این را حتماً
|
||||||
|
دستی تست کن.**
|
||||||
|
- `booking_locations` خالی → پیام روشن، نه صفحهٔ خالی.
|
||||||
|
- `422` سرویسِ ناسازگار → پیام فارسی خطای backend را نشان بده (پیام آماده است).
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **این تغییر بدون بهروزرسانی این سایت، ظرفیت واقعی پزشکان کلینیکی را از سایت حذف میکند.**
|
||||||
|
اولویت بالا.
|
||||||
|
- پاسخها double-nested هستند: `json?.data?.data ?? json?.data`.
|
||||||
|
- برای auth از `{ requireAuth: true }` استفاده کن (کوکی `access_token` → Bearer). endpointهای
|
||||||
|
اسلات عمومیاند و `removeTokenHead` میگیرند.
|
||||||
|
- slug پزشک = `uuid`.
|
||||||
|
- تاریخها شمسی (`jalali-moment`)، رشتههای جدید فارسی.
|
||||||
|
- شهر از subdomain: server با `lib/getStateInfo.js`، client با `useProvince()`. `city_id` موجود در
|
||||||
|
payload را دست نزن.
|
||||||
|
- تست دستی با کلینیک `41e325c4-e825-4067-8438-5d828ecaee09` و «دکتر تست» (`09100652121`) که در
|
||||||
|
همان کلینیک سرویس bookable دارد.
|
||||||
|
- بعد از تغییرات: `npm run lint` و `npm run build` هر دو باید سبز باشند.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# فلوی نوبتگیری: قفل واقعی ۱۵دقیقهای، فرم «نوبت برای شخص دیگر»، و UX وضعیتمحور
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — سایت عمومی. **بعد از پرامپت backend اجرا شود.**
|
||||||
|
|
||||||
|
> **Cross-repo:** وابسته به اصلاحات backend در
|
||||||
|
> `clinicpro/.claude/prompt/appointment-lock-patient-confirm.md`
|
||||||
|
> (فیلدهای `patient_*` و `for_self` در `POST /api/v1/appointment`، `expires_at` در پاسخ، تأیید پس از پرداخت). اگر آنها هنوز نیستند، **اول backend را اجرا کن.**
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
صفحهی `/appointment/[doctorId]` یک wizard چندمرحلهای است (`components/appointment/`): انتخاب تاریخ/ساعت → لاگین OTP → تکمیل اطلاعات (Detail) → پرداخت (Paying). در اصلاحات قبلی فلو به API واقعی وصل شد (ساخت نوبت در `SubmitData.js`، پرداخت در `paying/index.js`). اما:
|
||||||
|
|
||||||
|
- **تایمر پرداخت قلابی است:** `paying/index.js` یک `timeLeft = 600` (۱۰ دقیقه) شمارش معکوس محلی دارد که به قفل واقعی backend وصل نیست. باید ۱۵ دقیقه و بر مبنای `expires_at` واقعی نوبت باشد.
|
||||||
|
- **«نوبت برای شخص دیگر» فقط mock بود:** در `detail/index.js` دکمهی «دریافت نوبت برای فرد دیگر» دادهی ثابت ست میکند و فیلدهای واقعی بیمار (نام، موبایل، جنسیت، کد ملی، علت) را نمیگیرد و به backend نمیفرستد.
|
||||||
|
- **بدنهی ساخت نوبت فیلد بیمار/`for_self` ندارد.**
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
۱. فرم «نوبت برای شخص دیگر» با فیلدهای واقعی بیمار، و ارسال `patient_*` + `for_self` در ساخت نوبت.
|
||||||
|
۲. تایمر پرداخت بر مبنای `expires_at` واقعی نوبت (۱۵ دقیقه)؛ اتمام تایمر → نوبت منقضی، پیام مناسب، بازگشت به انتخاب ساعت.
|
||||||
|
۳. UX وضعیتمحور: پیامهای واضح برای رزرو همزمان (۴۰۹)، انقضا، و موفقیت.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/appointment/index.js` | state کل wizard (`data`, `isForAnother`, `selectedSlot`, `appointmentId`) |
|
||||||
|
| `components/appointment/detail/index.js` | فرم Detail + دکمهی «برای فرد دیگر» (الان mock) |
|
||||||
|
| `components/appointment/detail/Form.js` | رندر فیلدها |
|
||||||
|
| `components/appointment/detail/SubmitData.js` | `request.postAppointment` — بدنه باید `patient_*`/`for_self` بگیرد و `expires_at` را نگه دارد |
|
||||||
|
| `components/appointment/paying/index.js` | تایمر و پرداخت — تایمر باید از `expires_at` بیاید |
|
||||||
|
| `services/response.js` | `postAppointment` (بدنه از caller) |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
### `detail/index.js` — دکمهی mock «برای فرد دیگر»
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{!isForAnother && (
|
||||||
|
<Button onClick={() => {
|
||||||
|
setIsForAnother(true);
|
||||||
|
setData({
|
||||||
|
phone: { value: "09121056987", isEdit: true },
|
||||||
|
codemeli: { value: "1741025645", isEdit: true },
|
||||||
|
name: { value: "ساغر صابری نژاد", isEdit: true },
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
<AddCircleBlueA />
|
||||||
|
<p>دریافت نوبت برای فرد دیگر</p>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `SubmitData.js` — بدنهی نوبت بدون بیمار
|
||||||
|
|
||||||
|
```js
|
||||||
|
const appointmentPayload = {
|
||||||
|
doctor_uuid: doctor.uuid,
|
||||||
|
slot_start: selectedSlot.start,
|
||||||
|
slot_end: selectedSlot.end,
|
||||||
|
note: "",
|
||||||
|
};
|
||||||
|
const res = await request.postAppointment(appointmentPayload);
|
||||||
|
const appointmentUuid = res?.data?.uuid;
|
||||||
|
if (appointmentUuid) setAppointmentId(appointmentUuid);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `paying/index.js` — تایمر محلی ۶۰۰ ثانیه (قلابی)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const [timeLeft, setTimeLeft] = useState(600); // 10 minutes
|
||||||
|
useEffect(() => {
|
||||||
|
if (timeLeft === 0) return;
|
||||||
|
const timer = setInterval(() => setTimeLeft((p) => p - 1), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [timeLeft]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## قرارداد backend (بعد از اجرای پرامپت همتا)
|
||||||
|
|
||||||
|
`POST /api/v1/appointment` بدنه:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"doctor_uuid": "...", "slot_start": 1781933400, "slot_end": 1781934600,
|
||||||
|
"for_self": true,
|
||||||
|
"patient_name": "...", "patient_mobile": "09...", "patient_national_code": "...",
|
||||||
|
"patient_gender": "male", "patient_reason": "...", "note": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
پاسخ `201`: `{ success, data: { uuid, status:"pending", expires_at: 1781934300, patient_name, ... } }`
|
||||||
|
خطاها: `409` (اسلات همزمان رزرو شد)، `422` (فیلد بیمار ناقص وقتی for_self=false).
|
||||||
|
|
||||||
|
> interceptor یکبار باز میکند → داده در `res.data`. `expires_at` Unix ثانیه است.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. فرم «نوبت برای شخص دیگر» (`detail/index.js` + `Form.js`)
|
||||||
|
|
||||||
|
- دکمه را به یک toggle واقعی تبدیل کن: با `isForAnother=true` فیلدهای بیمار خالی و قابلویرایش شوند (نه دادهی mock).
|
||||||
|
- فیلدهای بیمار: نام، نام خانوادگی، شماره موبایل (هر دو اجباری وقتی برای دیگری)، جنسیت (اختیاری)، کد ملی (اختیاری)، علت مراجعه (اختیاری، textarea).
|
||||||
|
- وقتی «برای خودم»: همان رفتار فعلی (اطلاعات پروفایل، قابلویرایش).
|
||||||
|
- اعتبارسنجی حداقلی سمت کلاینت (نام و موبایل بیمار اجباری در حالت «برای دیگری»)؛ خطای فیلد را با همان الگوی `errors` موجود نشان بده.
|
||||||
|
|
||||||
|
### ۲. ارسال `patient_*` و `for_self` در `SubmitData.js`
|
||||||
|
|
||||||
|
- بدنه را گسترش بده:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const appointmentPayload = {
|
||||||
|
doctor_uuid: doctor.uuid,
|
||||||
|
slot_start: selectedSlot.start,
|
||||||
|
slot_end: selectedSlot.end,
|
||||||
|
for_self: !isForAnother,
|
||||||
|
note: data?.patient_reason?.value || "",
|
||||||
|
...(isForAnother ? {
|
||||||
|
patient_name: data?.name?.value,
|
||||||
|
patient_mobile: data?.phone?.value,
|
||||||
|
patient_national_code: data?.national_code?.value || "",
|
||||||
|
patient_gender: data?.gender?.value?.id || data?.gender?.value || "",
|
||||||
|
patient_reason: data?.patient_reason?.value || "",
|
||||||
|
} : {}),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- `isForAnother` را به `SubmitData` پاس بده (الان در `Container`/`Detail` هست).
|
||||||
|
- `expires_at` پاسخ را نگهدار و به مرحلهی پرداخت پاس بده (state جدید `appointmentExpiresAt` در `components/appointment/index.js`).
|
||||||
|
- خطای `409`: پیام «این زمان همین لحظه توسط کاربر دیگری رزرو شد» + بازگشت به مرحلهی انتخاب ساعت (`setStep(0)`) و تازهسازی اسلاتها.
|
||||||
|
- خطای `422`: پیامهای فیلد بیمار.
|
||||||
|
|
||||||
|
### ۳. تایمر پرداخت بر مبنای `expires_at` (`paying/index.js`)
|
||||||
|
|
||||||
|
- بهجای `useState(600)`، باقیمانده را از `expiresAt` (prop) حساب کن:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const computeLeft = () => Math.max(0, Math.floor((expiresAt * 1000 - Date.now()) / 1000));
|
||||||
|
const [timeLeft, setTimeLeft] = useState(computeLeft);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setTimeLeft(computeLeft()), 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [expiresAt]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- وقتی `timeLeft === 0`: دکمهی پرداخت غیرفعال، پیام «مهلت پرداخت تمام شد؛ لطفاً دوباره زمان نوبت را انتخاب کنید»، و دکمهای برای بازگشت به مرحلهی ۰.
|
||||||
|
- اگر `expiresAt` نبود (سازگاری)، fallback به ۹۰۰ ثانیه از زمان mount.
|
||||||
|
|
||||||
|
### ۴. UX نهایی
|
||||||
|
|
||||||
|
- در مرحلهی پرداخت، نام بیمار و زمان نوبت را نمایش بده (از همان دادهی نوبت).
|
||||||
|
- بعد از پرداخت موفق (بازگشت از درگاه به `/payment/result` یا step success)، پیام تأیید + اشاره به ارسال SMS.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **وابستگی cross-repo:** بدون فیلدهای `patient_*`/`for_self` و `expires_at` در backend این کار کامل نیست. اگر نبود متوقف شو.
|
||||||
|
- **پرداختکننده ≠ بیمار:** کاربر لاگینشده پرداختکننده است (از کوکی/توکن، خودکار)؛ فیلدهای بیمار جدا ارسال میشوند. این تفکیک را در UI هم روشن نشان بده.
|
||||||
|
- **زمانها Unix ثانیه:** `expires_at * 1000` برای مقایسه با `Date.now()`.
|
||||||
|
- **عدم رگرسیون:** فلوی OTP (step 1/2)، تقویم دوماهه، و گرید ساعت دستنخورده بمانند. منطق فعلی `SubmitData` برای PATCH/POST پروفایلِ کاربرِ «برای خودم» را حفظ کن (اطلاعات خودِ کاربر هنوز در پروفایلش ذخیره میشود؛ ولی فیلدهای بیمارِ نوبت جداگانه به نوبت میروند).
|
||||||
|
- **RTL/Jalali/multi-domain** حفظ شوند؛ فونت/کتابخانهی جدید اضافه نکن.
|
||||||
|
- **تست:** `npm run build`؛ سپس دستی: «برای دیگری» با نام/موبایل بیمار → نوبت ساخته شود؛ تایمر از ~۱۵:۰۰ بشمارد؛ رزرو همزمان روی یک اسلات → پیام ۴۰۹. سپس commit با پیام توصیفی برای هر قابلیت.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# ارسال city_id دامنهی جاری هنگام رزرو نوبت
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی). **cross-repo**: پرامپت همتا `clinicpro/.claude/prompt/representation-domain-guard-dashboard-settlement.md` است که **اول** اجرا میشود (backend فیلد `city_id` را در `POST /api/v1/appointment` میپذیرد و نمایندهی شهر را برای گاردِ پورسانت ذخیره میکند). این پرامپت front را بعد از آن اجرا کن.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
هر شهر دامنهی جداگانه دارد و از `data/city.json` تشخیص داده میشود (`getStateInfo().matchedCity` سرور، `ProvinceProvider`/`window.location.hostname` کلاینت). هر شهر `id` دارد (مثلاً اراک `129`) و فیلد `representation_id`. نماینده در backend به همان `city_id` خورده است.
|
||||||
|
|
||||||
|
برای اینکه backend بتواند تشخیص دهد نوبت از **دامنهی نماینده** ثبت شده (شرط لازم برای محاسبهی پورسانت)، سایت باید `city_id` دامنهی جاری را در payload رزرو بفرستد.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
در حال حاضر `appointmentPayload` در `components/appointment/detail/SubmitData.js` فیلد `city_id` ندارد. باید `matchedCity.id` دامنهی جاری به آن اضافه شود.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `nobat724_front/components/appointment/detail/SubmitData.js` | ساخت `appointmentPayload` و فراخوانی `postAppointment` |
|
||||||
|
| `nobat724_front/context/ProvinceProvider.js` | منبع client-side شهر جاری (`useProvince`) |
|
||||||
|
| `nobat724_front/lib/getStateInfo.js` | منبع server-side شهر (`matchedCity.id`) |
|
||||||
|
| `nobat724_front/data/city.json` | `id` و `domain` هر شهر |
|
||||||
|
| `nobat724_front/services/response.js` | `postAppointment` (بدون تغییر؛ فقط payload عوض میشود) |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
`components/appointment/detail/SubmitData.js` (پس از تغییرِ قبلیِ national_code/gender):
|
||||||
|
```js
|
||||||
|
const appointmentPayload = {
|
||||||
|
doctor_uuid: doctor.uuid,
|
||||||
|
slot_start: selectedSlot.start,
|
||||||
|
slot_end: selectedSlot.end,
|
||||||
|
for_self: !isForAnother,
|
||||||
|
note: data?.patient_reason?.value || "",
|
||||||
|
patient_national_code: data?.national_code?.value || "",
|
||||||
|
patient_gender: data?.gender?.value?.id || data?.gender?.value || "",
|
||||||
|
...(isForAnother ? { patient_name: ..., patient_mobile: ..., patient_reason: ... } : {}),
|
||||||
|
};
|
||||||
|
const res = await request.postAppointment(appointmentPayload);
|
||||||
|
```
|
||||||
|
|
||||||
|
`ProvinceProvider` (client) شهر را از `window.location.hostname` تشخیص میدهد. باید بررسی شود چه چیزی expose میکند — اگر فقط `isProvinceInclude` دارد، یک getter برای `matchedCity` (یا حداقل `matchedCity.id`) به آن اضافه شود تا کامپوننت کلاینت به `city.id` دسترسی داشته باشد.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. در دسترس قرار دادن city.id در کلاینت
|
||||||
|
|
||||||
|
اگر `useProvince()` فقط `isProvinceInclude` میدهد، آن را گسترش بده تا `matchedCity` (یا `cityId`) هم برگرداند. منطق تطبیق همان `getStateInfo` است: `subdomain = hostname.split('.')[0]`، سپس `citiesData.find(c => c.domain.split('.')[0] === subdomain)`. (در dev، `HOST=yazd-nobat.localhost` ⇒ subdomain = `yazd-nobat`.)
|
||||||
|
|
||||||
|
### ۲. افزودن city_id به payload رزرو
|
||||||
|
|
||||||
|
در `SubmitData.js`:
|
||||||
|
```js
|
||||||
|
import { useProvince } from "@/context/ProvinceProvider";
|
||||||
|
// ...
|
||||||
|
const { matchedCity } = useProvince(); // یا cityId مستقیم
|
||||||
|
|
||||||
|
const appointmentPayload = {
|
||||||
|
doctor_uuid: doctor.uuid,
|
||||||
|
slot_start: selectedSlot.start,
|
||||||
|
slot_end: selectedSlot.end,
|
||||||
|
for_self: !isForAnother,
|
||||||
|
note: data?.patient_reason?.value || "",
|
||||||
|
patient_national_code: data?.national_code?.value || "",
|
||||||
|
patient_gender: data?.gender?.value?.id || data?.gender?.value || "",
|
||||||
|
city_id: matchedCity?.id ?? null, // ← جدید
|
||||||
|
...(isForAnother ? { ... } : {}),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- `city_id` همان `matchedCity.id` از `city.json` است (نه `representation_id` که ممکن است null باشد). backend خودش نمایندهی آن شهر را پیدا میکند.
|
||||||
|
- اگر شهر تشخیص داده نشد یا نماینده نداشت، `city_id` خالی/null ⇒ backend پورسانت محاسبه نمیکند. این رفتار مطلوب است؛ خطا نده.
|
||||||
|
- مبلغ پرداختیِ کاربر تغییر نمیکند؛ این فقط متادیتای رزرو است.
|
||||||
|
- App Router + کامپوننت کلاینت: `useProvince` فقط در client component کار میکند؛ `SubmitData` کلاینت است.
|
||||||
|
- بعد از تغییر: `npm run build` برای صحت. قرارداد `POST /api/v1/appointment` در `clinicpro/docs/api/appointment.md` (سمت backend) بهروز است؛ فقط مطمئن شو نام فیلد `city_id` با backend یکی است.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# بهینهسازی SEO فیلدهای city.json — افزودن title برنددار + بازنویسی slogan و keywords
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
سایت چند-دامنهای است: هر شهر دامنه خودش را دارد (`tabriz-nobat.ir`، `esf-nobat.ir`، ...) و همه SEO هر شهر از `data/city.json` تغذیه میشود. `app/layout.js` در `generateMetadata` اول `matchedCity?.title` را میخواند و چون **هیچکدام از ۳۴ رکورد شهر فیلد `title` ندارند**، همیشه به الگوی fallback با برند سراسری «نوبت 724» میافتد — در حالی که هر شهر برند محلی خودش (`site_name` مثل «اراک نوبت») را دارد. `slogan` هم متن قابل ایندکس صفحات است (hero صفحه اصلی، about-us، پوستر پزشک) و باید هم مفهوم استارتآپی داشته باشد هم کلمه کلیدی شهر.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
نقش: متخصص حرفهای SEO فارسی (local SEO سلامت/نوبتدهی پزشکی).
|
||||||
|
|
||||||
|
سه فیلد در هر ۳۴ رکورد `data/city.json` باید ساخته/بازنویسی شود:
|
||||||
|
|
||||||
|
1. **`title`** (فیلد جدید): عنوان متا برای هر شهر که **حتماً برند محلی (`site_name`) در آن باشد**. الگو:
|
||||||
|
`نوبتدهی آنلاین پزشکان <name> | <site_name>`
|
||||||
|
مثال هدف: `"title": "نوبتدهی آنلاین پزشکان اراک | اراک نوبت"`
|
||||||
|
2. **`slogan`**: بازنویسی با مفهوم استارتآپی + SEO (کلمه کلیدی شهر داخلش باشد).
|
||||||
|
3. **`keywords`**: بهینهسازی با کلمات کلیدی محلی و long-tail واقعی جستجوی فارسی.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `nobat724_front/data/city.json` | **تنها فایل قابل تغییر** — ۳۴ رکورد شهر |
|
||||||
|
| `nobat724_front/app/layout.js` | مصرفکننده — فقط بخوان، تغییر نده |
|
||||||
|
| `nobat724_front/components/home/TextHeader.js` | نمایش `slogan` در hero صفحه اصلی |
|
||||||
|
| `nobat724_front/components/aboutUs/index.js` | نمایش `slogan` در about-us |
|
||||||
|
| `nobat724_front/components/doctor/poster/index.js` | نمایش `slogan` در پوستر پزشک |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
`app/layout.js` (تغییر نمیکند — از قبل `title` را میخواند):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const title =
|
||||||
|
matchedCity?.title ||
|
||||||
|
(matchedCity?.name
|
||||||
|
? `نوبتدهی آنلاین پزشکان ${matchedCity.name} | نوبت 724`
|
||||||
|
: "نوبت 724 | سیستم نوبتدهی آنلاین پزشکی");
|
||||||
|
```
|
||||||
|
|
||||||
|
نمونه رکورد فعلی `data/city.json` (فیلد `title` وجود ندارد):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"name": "تبریز",
|
||||||
|
"site_name": "تبریز نوبت",
|
||||||
|
"description": "نوبت تبریز، سامانه تخصصی نوبتدهی پزشکی شمالغرب کشور است. ...",
|
||||||
|
"slogan": "سریعترین نوبتدهی پزشکی تبریز",
|
||||||
|
"domain": "tabriz-nobat.ir",
|
||||||
|
"keywords": "نوبت تبریز, دکتر تبریز, پزشک متخصص تبریز, رزرو نوبت اینترنتی تبریز, کلینیک تبریز, بیمارستان تبریز, نوبت آنلاین آذربایجان شرقی",
|
||||||
|
"province_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. افزودن فیلد `title` به هر ۳۴ رکورد
|
||||||
|
|
||||||
|
بعد از فیلد `site_name` (یا کنار فیلدهای SEO) در هر رکورد اضافه کن:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"title": "نوبتدهی آنلاین پزشکان تبریز | تبریز نوبت"
|
||||||
|
```
|
||||||
|
|
||||||
|
قواعد:
|
||||||
|
- الگوی ثابت: `نوبتدهی آنلاین پزشکان <name> | <site_name>` — کلمه کلیدی اصلی اول، برند آخر.
|
||||||
|
- جداکننده ` | ` با فاصله در دو طرف (مثال کاربر فاصله نداشت — نسخه استاندارد با فاصله بنویس).
|
||||||
|
- طول کل ≤ ۶۰ کاراکتر (برای عدم truncate در گوگل). همه نامهای شهر ایران در این الگو جا میشوند.
|
||||||
|
- `name` و `site_name` را **دقیقاً از همان رکورد** بردار — دستساز ننویس (مثلاً اصفهان → «اصفهان نوبت»).
|
||||||
|
- نیمفاصله در «نوبتدهی» حفظ شود ( = U+200C).
|
||||||
|
|
||||||
|
### ۲. بازنویسی `slogan` هر ۳۴ رکورد — استارتآپی + SEO
|
||||||
|
|
||||||
|
قواعد:
|
||||||
|
- حتماً نام شهر + یکی از کلمات کلیدی (نوبت / پزشک / دکتر) داخل جمله باشد — این متن در H-level صفحه اصلی ایندکس میشود.
|
||||||
|
- لحن استارتآپی: تمرکز بر سرعت، سادگی، دسترسی ۲۴ساعته، حذف صف — نه شعار خشک اداری.
|
||||||
|
- کوتاه: ۴ تا ۸ کلمه؛ برای هر شهر **یکتا** (duplicate content بین ۳۴ دامنه ممنوع — گوگل دامنهها را جدا ایندکس میکند ولی الگوی تکراری ارزش برند را میکشد).
|
||||||
|
- نمونههای جهتدهنده (کپی نکن، برای هر شهر متفاوت بساز):
|
||||||
|
- «نوبت دکتر در تبریز، سادهتر از همیشه»
|
||||||
|
- «پزشکان اصفهان، یک کلیک تا ویزیت»
|
||||||
|
- «سلامت اهواز، بدون صف و انتظار»
|
||||||
|
|
||||||
|
### ۳. بهینهسازی `keywords` هر ۳۴ رکورد
|
||||||
|
|
||||||
|
قواعد:
|
||||||
|
- ۷ تا ۱۰ عبارت، جدا با `, ` (فرمت فعلی حفظ شود — string، نه array).
|
||||||
|
- ترکیب اجباری برای هر شهر:
|
||||||
|
- `نوبت دکتر <شهر>` و `نوبتدهی آنلاین <شهر>` (پرجستجوترین الگوهای فارسی)
|
||||||
|
- `دکتر خوب <شهر>` (الگوی long-tail رایج)
|
||||||
|
- `پزشک متخصص <شهر>`
|
||||||
|
- یک عبارت استانی (مثل `نوبت آنلاین آذربایجان شرقی`) از `state.json` با `province_id`
|
||||||
|
- برند محلی: `<site_name>` (مثل `تبریز نوبت`)
|
||||||
|
- عبارتهای کمارزش/تکراری فعلی (مثل `کلینیک <شهر>, بیمارستان <شهر>` خالی) را با long-tail هدفمند جایگزین کن: `رزرو نوبت پزشک <شهر>`، `نوبت اینترنتی دکتر <شهر>`.
|
||||||
|
- keyword stuffing ممنوع — هر عبارت باید نیت جستجوی واقعی داشته باشد.
|
||||||
|
|
||||||
|
### ۴. اعتبارسنجی
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd nobat724_front
|
||||||
|
node -e "const d=require('./data/city.json'); \
|
||||||
|
console.assert(d.length===34,'count'); \
|
||||||
|
d.forEach(c=>{ \
|
||||||
|
if(!c.title) throw new Error('no title: '+c.name); \
|
||||||
|
if(!c.title.includes(c.site_name)) throw new Error('title missing site_name: '+c.name); \
|
||||||
|
if(c.title.length>60) throw new Error('title too long: '+c.name); \
|
||||||
|
if(!c.slogan.includes(c.name)&&!c.keywords.includes(c.name)) throw new Error('city name missing: '+c.name); \
|
||||||
|
}); \
|
||||||
|
const slogans=new Set(d.map(c=>c.slogan)); \
|
||||||
|
console.assert(slogans.size===34,'duplicate slogans'); \
|
||||||
|
console.log('OK')"
|
||||||
|
npm run build # فقط اگر سریع بود؛ حداقل: node parse بالا باید OK بدهد
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **فقط `data/city.json` تغییر میکند.** `app/layout.js` از قبل `matchedCity?.title` را میخواند؛ hero و about-us هم `slogan` را مستقیم نمایش میدهند — هیچ تغییر کدی لازم نیست.
|
||||||
|
- ساختار JSON دست نخورد: هیچ فیلدی حذف/تغییرنام نشود، ترتیب رکوردها حفظ شود، encoding UTF-8 و نیمفاصلهها سالم بمانند.
|
||||||
|
- `description` و `footer_description` در scope این پرامپت نیستند — ولی اگر با title/slogan جدید تناقض آشکار داشتند (مثلاً برند متفاوت)، فقط گزارش بده، تغییر نده.
|
||||||
|
- نام استان هر شهر از `data/state.json` با `province_id` قابل استخراج است.
|
||||||
|
- برای شهرهایی که `site_name` آنها با الگوی `<شهر> نوبت` نیست، همان مقدار واقعی فایل ملاک است.
|
||||||
|
- زبان همه مقادیر: فارسی (محصول تماماً فارسی/RTL است).
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# بهینهسازی SEO تکتک شهرها در city.json (سطح حرفهای)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی نوبتدهی، چند-دامنهای — هر شهر یک دامنه)
|
||||||
|
|
||||||
|
## نقش
|
||||||
|
|
||||||
|
تو یک **متخصص حرفهای SEO فارسی/محلی (Local SEO)** هستی. خروجی باید در سطح یک آژانس سئوی حرفهای باشد: یکتا، کلیدواژهمحور بدون stuffing، با رعایت طول بهینه، CTR-optimized.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
`data/city.json` منبع واحد SEO هر دامنهی شهری است. `app/layout.js` → `generateMetadata()` مقادیر این فایل را مستقیم به تگهای متا تبدیل میکند. یعنی کیفیت SEO هر شهر = کیفیت فیلدهای متنی همان رکورد در این فایل.
|
||||||
|
|
||||||
|
۳۴ رکورد وجود دارد (۳۳ شهر + رکورد اصلی `id:600` دامنه `nobat724.com`). محتوای فعلی با یک **قالب یکسان کلون** شده — جملات تقریباً تکراری بین شهرها (خطر duplicate content) و ضعیف.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
دو مشکل بحرانی:
|
||||||
|
|
||||||
|
1. **فیلد `title` در هیچکدام از ۳۴ رکورد وجود ندارد.** `layout.js` اول `matchedCity?.title` را میخواند؛ چون نیست، همه شهرها به یک قالب یکسان fallback میکنند → تایتلهای تقریباً تکراری. **باید به هر رکورد فیلد `title` یکتا و بهینه اضافه شود.**
|
||||||
|
2. `description` / `keywords` / `slogan` / `footer_description` قالبی و تکراریاند → باید هرکدام برای هر شهر **بازنویسی یکتا و بهینه** شوند.
|
||||||
|
|
||||||
|
هدف: هر ۳۴ رکورد، ۵ فیلد متنی SEO در بالاترین کیفیت، **بدون تکرار جمله بین شهرها**.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `data/city.json` | تنها فایلی که ویرایش میشود — رکورد هر شهر |
|
||||||
|
| `app/layout.js` | مصرفکننده فیلدها (فقط برای درک، ویرایش نشود مگر task ۶) |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
مصرف فیلدها در `app/layout.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const title =
|
||||||
|
matchedCity?.title || // ← این فیلد وجود ندارد!
|
||||||
|
(matchedCity?.name
|
||||||
|
? `نوبتدهی آنلاین پزشکان ${matchedCity.name} | نوبت 724`
|
||||||
|
: "نوبت 724 | سیستم نوبتدهی آنلاین پزشکی");
|
||||||
|
const description = matchedCity?.description || "...";
|
||||||
|
// keywords: matchedCity.keywords
|
||||||
|
// openGraph.siteName: matchedCity.site_name
|
||||||
|
```
|
||||||
|
|
||||||
|
نمونه رکورد فعلی (تکراری/قالبی):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 113, "name": "اهواز", "site_name": "اهواز نوبت",
|
||||||
|
"province_name": "خوزستان",
|
||||||
|
"description": "اهواز نوبت، سامانه نوبتدهی آنلاین در اهواز و استان خوزستان. امکان جستجو و رزرو نوبت در مراکز درمانی. اهواز نوبت بهترین راه دسترسی به پزشکان متخصص است.",
|
||||||
|
"slogan": "سامانه هوشمند نوبتدهی پزشکان اهواز",
|
||||||
|
"keywords": "رزرو نوبت اهواز, پزشکان خوزستان, کلینیکهای اهواز, بیمارستانهای اهواز, متخصصین پزشکی اهواز",
|
||||||
|
"footer_description": "اهواز نوبت، سامانه جامع نوبتدهی پزشکی در استان خوزستان. رزرو نوبت از پزشکان و مراکز درمانی اهواز بدون نیاز به مراجعه حضوری."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## فهرست ۳۴ رکورد (همه باید پردازش شوند)
|
||||||
|
|
||||||
|
| id | شهر | استان | دامنه |
|
||||||
|
|----|-----|-------|-------|
|
||||||
|
| 129 | اراک | مرکزی | arak-nobat.ir |
|
||||||
|
| 103 | اردبیل | اردبیل | ardabil-nobat.ir |
|
||||||
|
| 102 | ارومیه | آذربایجان غربی | urmia-nobat.ir |
|
||||||
|
| 104 | اصفهان | اصفهان | esf-nobat.ir |
|
||||||
|
| 113 | اهواز | خوزستان | ahvaz-nobat.ir |
|
||||||
|
| 106 | ایلام | ایلام | ilam-nobat.ir |
|
||||||
|
| 112 | بجنورد | خراسان شمالی | bojnord-nobat.ir |
|
||||||
|
| 130 | بندرعباس | هرمزگان | bandar-nobat.ir |
|
||||||
|
| 107 | بوشهر | بوشهر | bushehr-nobat.ir |
|
||||||
|
| 110 | بیرجند | خراسان جنوبی | birjand-nobat.ir |
|
||||||
|
| 101 | تبریز | آذربایجان شرقی | tabriz-nobat.ir |
|
||||||
|
| 108 | تهران | تهران | tehran-nobat.ir |
|
||||||
|
| 127 | خرمآباد | لرستان | lorestan-nobat.ir |
|
||||||
|
| 126 | رشت | گیلان | rasht-nobat.ir |
|
||||||
|
| 116 | زاهدان | سیستان و بلوچستان | zahedan-nobat.ir |
|
||||||
|
| 114 | زنجان | زنجان | zanjan-nobat.ir |
|
||||||
|
| 128 | ساری | مازندران | sari-nobat.ir |
|
||||||
|
| 115 | سمنان | سمنان | semnan-nobat.ir |
|
||||||
|
| 120 | سنندج | کردستان | sanandaj-nobat.ir |
|
||||||
|
| 109 | شهرکرد | چهارمحال و بختیاری | shkord-nobat.ir |
|
||||||
|
| 117 | شیراز | فارس | shiraz-nobat.ir |
|
||||||
|
| 118 | قزوین | قزوین | qazvin-nobat.ir |
|
||||||
|
| 119 | قم | قم | qom-nobat.ir |
|
||||||
|
| 105 | کرج | البرز | karaj-nobat.ir |
|
||||||
|
| 121 | کرمان | کرمان | kerman-nobat.ir |
|
||||||
|
| 122 | کرمانشاه | کرمانشاه | kermanshah-nobat.ir |
|
||||||
|
| 125 | گرگان | گلستان | golestan-nobat.ir |
|
||||||
|
| 111 | مشهد | خراسان رضوی | mashhad-nobat.ir |
|
||||||
|
| 131 | همدان | همدان | hamadan-nobat.ir |
|
||||||
|
| 123 | یاسوج | کهگیلویه و بویراحمد | yasuj-nobat.ir |
|
||||||
|
| 132 | یزد | یزد | yazd-nobat.ir |
|
||||||
|
| 133 | دهدشت | کهگیلویه و بویراحمد | dehdasht-nobat.ir |
|
||||||
|
| 134 | بهبهان | خوزستان | behbahan-nobat.ir |
|
||||||
|
| 600 | نوبت 724 (برند اصلی) | — | nobat724.com |
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. افزودن فیلد `title` یکتا به هر رکورد (مهمترین)
|
||||||
|
|
||||||
|
بعد از `site_name` در هر رکورد، فیلد `title` اضافه کن.
|
||||||
|
|
||||||
|
**قواعد `title`:**
|
||||||
|
- طول: **۵۰–۶۰ کاراکتر** (حداکثر ~۶۰ برای عدم truncate در گوگل).
|
||||||
|
- کلیدواژه اصلی **اول** جمله: «نوبتدهی آنلاین پزشکان {شهر}» یا واریانت.
|
||||||
|
- برند در انتها با جداکننده `|`: `... | نوبت ۷۲۴`.
|
||||||
|
- **یکتا بین شهرها** — فقط جایگذاری اسم شهر کافی نیست؛ ساختار/کلمات را بین گروهها متنوع کن (مثلاً برخی «رزرو نوبت پزشک {شهر}»، برخی «نوبت اینترنتی دکتر {شهر}»).
|
||||||
|
- بدون stuffing، خوانا، طبیعی.
|
||||||
|
|
||||||
|
مثال طلایی:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"title": "نوبتدهی آنلاین پزشکان اهواز | رزرو نوبت دکتر | نوبت ۷۲۴"
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. بازنویسی `description` (متا دیسکریپشن)
|
||||||
|
|
||||||
|
**قواعد:**
|
||||||
|
- طول: **۱۵۰–۱۶۰ کاراکتر** (نه کمتر از ۱۲۰، نه بیشتر از ۱۶۵).
|
||||||
|
- کلیدواژه اصلی front-load شده در ابتدای جمله.
|
||||||
|
- شامل: نام شهر + استان + اشاره به تخصصها/مراکز درمانی + **CTA** («همین حالا رزرو کنید» / «آنلاین نوبت بگیرید»).
|
||||||
|
- **یکتا** — هیچ دو شهری جملهی یکسان نداشته باشند. زاویهی متن را متنوع کن (یکی روی سرعت، یکی روی تنوع پزشکان، یکی روی ۲۴ساعتهبودن).
|
||||||
|
- بدون تکرار بیش از حد اسم شهر (حداکثر ۲ بار).
|
||||||
|
|
||||||
|
مثال طلایی:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"description": "با نوبت اهواز، آنلاین از پزشکان متخصص و عمومی خوزستان نوبت بگیرید. جستجوی دکتر بر اساس تخصص، بیمه و منطقه در اهواز؛ رزرو نوبت ۲۴ساعته بدون مراجعه حضوری. همین حالا نوبت خود را ثبت کنید."
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳. بهینهسازی `keywords`
|
||||||
|
|
||||||
|
- ۶–۹ عبارت هدفمند، جدا با `, `.
|
||||||
|
- ترکیب: `نوبت {شهر}`، `دکتر {شهر}`، `پزشک متخصص {شهر}`، `رزرو نوبت اینترنتی {شهر}`، `کلینیک {شهر}`، `بهترین پزشک {استان}`، عبارات intent-محور («نوبت آنلاین دکتر»).
|
||||||
|
- بدون تکرار عینبهعین عبارات بین شهرها (اسم شهر متفاوت کافی نیست برای long-tailها).
|
||||||
|
|
||||||
|
### ۴. بازنویسی `slogan` یکتا
|
||||||
|
|
||||||
|
- کوتاه (۴–۸ کلمه)، جذاب، دارای کلیدواژه شهر، متمایز بین شهرها.
|
||||||
|
- نمونه: `"در اهواز، نوبت دکتر فقط با چند کلیک"`.
|
||||||
|
|
||||||
|
### ۵. بازنویسی `footer_description` یکتا
|
||||||
|
|
||||||
|
- ۱–۲ جمله، محلی، دارای شهر+استان، متفاوت از `description` (نه کپی).
|
||||||
|
- برای فوتر — لحن معرفی برند محلی.
|
||||||
|
|
||||||
|
### ۶. (اختیاری، اگر کاربر خواست) بهبود فنی در `layout.js`
|
||||||
|
|
||||||
|
اگر زمان بود، اینها را هم پیشنهاد بده (اما JSON اولویت است):
|
||||||
|
- `openGraph.images` و `twitter.images` الان هاردکد `nobat724.com/assets/images/logo.png` است — میتوان per-city کرد.
|
||||||
|
- JSON-LD فعلی فقط `Organization` + `WebSite` با `siteUrl` ثابت `nobat724.com` است؛ افزودن `MedicalBusiness`/`LocalBusiness` per-city با دامنه و نام شهر، سیگنال Local SEO قوی میدهد.
|
||||||
|
|
||||||
|
### ۷. رکورد برند اصلی (`id: 600`)
|
||||||
|
|
||||||
|
این رکورد شهر نیست (دامنهی مادر `nobat724.com`, `province_name: null`). برایش `title`/`description`/`keywords` سراسری و برندمحور بنویس (نه شهرمحور): تمرکز روی «سامانه سراسری نوبتدهی آنلاین پزشکی ایران».
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **فقط `data/city.json` ویرایش شود** (بهجز task ۶ اختیاری). ساختار JSON، ترتیب رکوردها، و فیلدهای `id`, `uuid`, `status`, `weight`, `province_id`, `province_name`, `representation_id`, `contact_phone`, `email`, `domain`, `social_media`, `logo_url` **دستنخورده** بمانند.
|
||||||
|
- فیلد `title` جدید را بعد از `site_name` قرار بده (سازگاری با ترتیب منطقی).
|
||||||
|
- **اعداد در `title` فارسی** باشند (`۷۲۴` نه `724`) برای هماهنگی با UI فارسی — مگر اینکه در برند لاتین لازم باشد.
|
||||||
|
- **یکتایی سراسری**: بعد از اتمام، هیچ دو رکوردی نباید `title` یا `description` با ساختار جملهی یکسان (فقط تغییر اسم شهر) داشته باشند. حداقل ۳–۴ الگوی متنی متفاوت بین ۳۴ رکورد بچرخان.
|
||||||
|
- **صحت جغرافیایی**: `province_name` هر رکورد را بهعنوان واقعیت بپذیر (قبلاً بهبهان=خوزستان، دهدشت=کهگیلویه اصلاح شده). محتوای استان را با همین مقدار بنویس.
|
||||||
|
- بعد از ویرایش، **اعتبار JSON را تأیید کن**:
|
||||||
|
```bash
|
||||||
|
python3 -c "import json; d=json.load(open('data/city.json')); print('valid', len(d)); \
|
||||||
|
t=[c.get('title') for c in d]; assert all(t), 'missing title'; \
|
||||||
|
print('unique titles:', len(set(t))==len(t)); \
|
||||||
|
print('unique descriptions:', len(set(c['description'] for c in d))==len(d))"
|
||||||
|
```
|
||||||
|
باید: valid، همه title دارند، titleها و descriptionها یکتا.
|
||||||
|
- زبان فارسی رسمی و روان، RTL، بدون غلط املایی. اسامی شهر/استان دقیقاً مطابق فایل.
|
||||||
|
- این تغییر داده است، نه endpoint API → نیازی به بهروزرسانی `docs/api/` نیست.
|
||||||
|
- بعد از ویرایش، یک build سریع بزن که فایل درست پارس میشود: `npm run build` (یا حداقل import شدن JSON).
|
||||||
|
```
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# اصلاح بخش «اطلاعات تماس» صفحه کلینیک
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
صفحه عمومی کلینیک (`/clinic/[uuid]`) یک کارت «اطلاعات تماس» دارد که روزهای کاری، تلفن، آدرس و نقشه را نشان میدهد. روی نمونه واقعی:
|
||||||
|
|
||||||
|
`http://karaj-nobat.localhost:3000/clinic/bcb00726-2343-4d63-90c6-d0175cc74591`
|
||||||
|
|
||||||
|
پاسخ واقعی API (`GET /api/v1/clinic/{uuid}` روی `https://clinic-pro.ddev.site`) این است:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "کلینیک تست QA",
|
||||||
|
"title": "کلینیک تست QA",
|
||||||
|
"phone": "03531234567",
|
||||||
|
"phone_number": "03531234567",
|
||||||
|
"city": [{ "id": "105", "name": "کرج", "parent": "5" }],
|
||||||
|
"state": [{ "id": "5", "name": "البرز" }],
|
||||||
|
"location": "یزد، خیابان تست QA",
|
||||||
|
"map": { "latitude": null, "longitude": null },
|
||||||
|
"24_7": false,
|
||||||
|
"field_working_days": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
اطلاعات تماسی که روی صفحه رندر میشود با این داده همخوان نیست.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
۱. **شهر/استان اصلاً نمایش داده نمیشود.** API فیلدهای `city[0].name` و `state[0].name` را میفرستد ولی UI فقط رشتهٔ آزاد `location` را چاپ میکند. نتیجه: کاربر روی دامنهٔ کرج آدرس «یزد، خیابان تست QA» میبیند بدون هیچ نشانهای از شهر واقعی کلینیک (کرج/البرز).
|
||||||
|
|
||||||
|
۲. **تلفن خام رندر میشود.** `03531234567` بدون هیچ جداکنندهای نمایش داده میشود و مقدار `href="tel:..."` هم مستقیم از همان رشته ساخته میشود (اگر مقدار DB فاصله یا `-` داشته باشد، `tel:` خراب میشود).
|
||||||
|
|
||||||
|
۳. **ناسازگاری منبع تلفن بین UI و JSON-LD.** کامپوننت `Detail.js` از `phone_number || phone` میخواند اما JSON-LD در `page.js` فقط `clinic.phone` را میخواند. اگر یکی پر و دیگری خالی باشد، صفحه و structured data دو چیز متفاوت میگویند.
|
||||||
|
|
||||||
|
۴. **`PostalAddress` در JSON-LD ناقص است.** فقط `streetAddress` و `addressCountry` دارد؛ `addressLocality` (شهر) و `addressRegion` (استان) ندارند در حالی که دادهاش موجود است.
|
||||||
|
|
||||||
|
۵. **کارت خالی.** وقتی `field_working_days` و `phone` و `location` همه null باشند و مختصات هم نباشد، `Detail` مقدار `null` برمیگرداند و `mapQuery` خالی است — ولی کارت `<div className="border ... rounded-[16px] ...">` همچنان رندر میشود و یک باکس خالی روی صفحه میماند.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `nobat724_front/components/clinic/components/contact/Detail.js` | رندر ردیفهای روزهای کاری / تلفن / آدرس |
|
||||||
|
| `nobat724_front/components/clinic/components/contact/index.js` | کارت اطلاعات تماس + نقشه + دکمه مسیریابی |
|
||||||
|
| `nobat724_front/components/clinic/components/contact/ContentDetail.js` | ردیف `head`/`detail` (تغییر ندارد) |
|
||||||
|
| `nobat724_front/app/clinic/[slug]/page.js` | JSON-LD نوع `MedicalClinic` (فیلدهای `telephone` و `address`) |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
`components/clinic/components/contact/Detail.js`:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
function Detail({ data }) {
|
||||||
|
const workingDays = data?.["24_7"]
|
||||||
|
? "۲۴ ساعته، تمام روزهای هفته"
|
||||||
|
: data?.field_working_days?.trim();
|
||||||
|
const phone = (data?.phone_number || data?.phone || "").trim();
|
||||||
|
const address = data?.location?.trim();
|
||||||
|
|
||||||
|
if (!workingDays && !phone && !address) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col items-start justify-start gap-[16px]">
|
||||||
|
{workingDays && (
|
||||||
|
<ContentDetail head="روزهای کاری: " detail={workingDays} />
|
||||||
|
)}
|
||||||
|
{phone && (
|
||||||
|
<ContentDetail
|
||||||
|
head="تلفن: "
|
||||||
|
detail={
|
||||||
|
<a href={`tel:${phone}`} dir="ltr" className="hover:text-[#5559CE]">
|
||||||
|
{phone}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{address && <ContentDetail head="آدرس: " detail={address} />}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`app/clinic/[slug]/page.js` (بخش JSON-LD):
|
||||||
|
|
||||||
|
```js
|
||||||
|
...(clinic.phone && { telephone: clinic.phone }),
|
||||||
|
...(clinic.location && {
|
||||||
|
address: {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
streetAddress: clinic.location,
|
||||||
|
addressCountry: "IR",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. ساخت helper مشترک برای اطلاعات تماس کلینیک
|
||||||
|
|
||||||
|
فایل جدید `nobat724_front/lib/clinicContact.js` بساز تا هم UI و هم JSON-LD از یک منبع بخوانند (رفع مشکل ۳):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// lib/clinicContact.js
|
||||||
|
|
||||||
|
/** API هر دو کلید را میفرستد؛ یکی ممکن است null باشد. یک منبع واحد. */
|
||||||
|
export function getClinicPhone(clinic) {
|
||||||
|
return (clinic?.phone_number || clinic?.phone || "").toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** فقط رقم — برای href="tel:" تا فاصله/خط تیرهٔ داخل DB لینک را خراب نکند. */
|
||||||
|
export function telHref(phone) {
|
||||||
|
const digits = (phone || "").replace(/[^\d+]/g, "");
|
||||||
|
return digits ? `tel:${digits}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 03531234567 → +983531234567 برای schema.org telephone */
|
||||||
|
export function toE164Ir(phone) {
|
||||||
|
const d = (phone || "").replace(/\D/g, "");
|
||||||
|
if (!d) return null;
|
||||||
|
if (d.startsWith("98")) return `+${d}`;
|
||||||
|
if (d.startsWith("0")) return `+98${d.slice(1)}`;
|
||||||
|
return `+98${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClinicCity(clinic) {
|
||||||
|
return clinic?.city?.[0]?.name?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClinicState(clinic) {
|
||||||
|
return clinic?.state?.[0]?.name?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* آدرس نمایشی: «استان، شهر — نشانی».
|
||||||
|
* اگر خودِ location قبلاً نام شهر را داشته باشد دوباره تکرار نمیشود.
|
||||||
|
*/
|
||||||
|
export function getClinicAddress(clinic) {
|
||||||
|
const street = clinic?.location?.trim() || "";
|
||||||
|
const city = getClinicCity(clinic);
|
||||||
|
const state = getClinicState(clinic);
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (state && !street.includes(state)) parts.push(state);
|
||||||
|
if (city && !street.includes(city)) parts.push(city);
|
||||||
|
if (street) parts.push(street);
|
||||||
|
|
||||||
|
return parts.length ? parts.join("، ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** آیا اصلاً چیزی برای نمایش در کارت تماس هست؟ */
|
||||||
|
export function hasClinicContact(clinic) {
|
||||||
|
return Boolean(
|
||||||
|
clinic?.["24_7"] ||
|
||||||
|
clinic?.field_working_days?.trim() ||
|
||||||
|
getClinicPhone(clinic) ||
|
||||||
|
getClinicAddress(clinic) ||
|
||||||
|
(clinic?.map?.latitude && clinic?.map?.longitude)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. اصلاح `Detail.js`
|
||||||
|
|
||||||
|
- تلفن و آدرس را از helper بگیر.
|
||||||
|
- `tel:` را از `telHref()` بساز؛ اگر null بود فقط متن ساده رندر کن (نه لینک شکسته).
|
||||||
|
- شهر/استان را در ردیف آدرس نشان بده.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import ContentDetail from "./ContentDetail";
|
||||||
|
import { getClinicPhone, telHref, getClinicAddress } from "@/lib/clinicContact";
|
||||||
|
|
||||||
|
function Detail({ data }) {
|
||||||
|
const workingDays = data?.["24_7"]
|
||||||
|
? "۲۴ ساعته، تمام روزهای هفته"
|
||||||
|
: data?.field_working_days?.trim();
|
||||||
|
const phone = getClinicPhone(data);
|
||||||
|
const href = telHref(phone);
|
||||||
|
const address = getClinicAddress(data);
|
||||||
|
|
||||||
|
if (!workingDays && !phone && !address) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col items-start justify-start gap-[16px]">
|
||||||
|
{workingDays && <ContentDetail head="روزهای کاری: " detail={workingDays} />}
|
||||||
|
{phone && (
|
||||||
|
<ContentDetail
|
||||||
|
head="تلفن: "
|
||||||
|
detail={
|
||||||
|
href ? (
|
||||||
|
<a href={href} dir="ltr" className="hover:text-[#5559CE]">
|
||||||
|
{phone}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span dir="ltr">{phone}</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{address && <ContentDetail head="آدرس: " detail={address} />}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳. جلوگیری از کارت خالی در `contact/index.js`
|
||||||
|
|
||||||
|
در ابتدای `Contact`، اگر `hasClinicContact(data)` نادرست بود `null` برگردان تا باکس border-dar خالی رندر نشود:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { hasClinicContact } from "@/lib/clinicContact";
|
||||||
|
|
||||||
|
function Contact({ data }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
// ...
|
||||||
|
if (!hasClinicContact(data)) return null;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> هوکها باید **قبل** از این return صدا زده شوند (قانون hooks) — `useState` را بالای شرط نگه دار.
|
||||||
|
|
||||||
|
همچنین در ساخت `mapQuery`، بهجای `data.location` از `getClinicAddress(data)` استفاده کن تا وقتی مختصات نیست، کوئری نقشه شامل شهر/استان باشد و پین به شهر درست بیفتد (نمونهٔ فعلی: `location = "یزد، خیابان تست QA"` ولی شهر واقعی «کرج» است — بدون شهر، نقشه یزد را نشان میدهد).
|
||||||
|
|
||||||
|
### ۴. تکمیل JSON-LD در `app/clinic/[slug]/page.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
import {
|
||||||
|
getClinicPhone,
|
||||||
|
toE164Ir,
|
||||||
|
getClinicCity,
|
||||||
|
getClinicState,
|
||||||
|
} from "@/lib/clinicContact";
|
||||||
|
|
||||||
|
// ...
|
||||||
|
const clinicPhone = getClinicPhone(clinic);
|
||||||
|
const clinicCity = getClinicCity(clinic);
|
||||||
|
const clinicState = getClinicState(clinic);
|
||||||
|
|
||||||
|
const jsonLd = clinic ? {
|
||||||
|
// ...
|
||||||
|
...(clinicPhone && { telephone: toE164Ir(clinicPhone) }),
|
||||||
|
...((clinic.location || clinicCity) && {
|
||||||
|
address: {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
...(clinic.location && { streetAddress: clinic.location.trim() }),
|
||||||
|
...(clinicCity && { addressLocality: clinicCity }),
|
||||||
|
...(clinicState && { addressRegion: clinicState }),
|
||||||
|
addressCountry: "IR",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// ...
|
||||||
|
} : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **Server/Client:** `contact/index.js` کلاینت است (`useState`)؛ `lib/clinicContact.js` باید pure و بدون وابستگی به `next/headers` بماند تا هم در Server Component (`page.js`) و هم در Client Component قابل import باشد.
|
||||||
|
- **مقدار `null` رشتهای:** API برای فیلدهای پرنشده `null` میفرستد؛ هیچجا مستقیم داخل template string نگذار (کامنت موجود در `Detail.js` همین را هشدار میدهد) — همهٔ helperها باید `null` برگردانند نه رشتهٔ خالیِ درجشده.
|
||||||
|
- **`24_7` کلید عددیشروع است** — همیشه با bracket notation (`clinic["24_7"]`) خوانده شود.
|
||||||
|
- **تکرار شهر:** بعضی رکوردها نام شهر را داخل خود `location` دارند؛ منطق `getClinicAddress` باید تکرار را حذف کند (تست: `location="کرج، بلوار..."` + `city="کرج"` → خروجی نباید «کرج، کرج، بلوار...» باشد).
|
||||||
|
- **JSON-LD sanitize:** خروجی همچنان باید از `safeJsonLd()` عبور کند (الگوی فعلی `page.js`).
|
||||||
|
- **تغییر backend لازم نیست** — همهٔ فیلدها (`city`, `state`, `phone`, `phone_number`, `location`) در پاسخ فعلی API موجودند.
|
||||||
|
- **بررسی رگرسیون:** اگر صفحهٔ پزشک (`components/doctor/...`) هم آدرس کلینیک را همینطور رندر میکند، فقط گزارش بده — در این تسک تغییرش نده.
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
# Schema.org کامل و Knowledge Panel برای صفحه کلینیک
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — پیشنیاز: `clinicpro/.claude/prompt/clinic-social-media-field.md` باید اجرا شده باشد تا فیلد `social_media` در API کلینیک موجود باشد.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
صفحه `/clinic/[slug]/page.js` در حال حاضر فقط یک `MedicalClinic` ساده با `name` و `image` دارد. هدف این است که با افزودن Schema.org کامل، گوگل بتواند Knowledge Panel برای هر کلینیک نمایش دهد — شامل آدرس، تلفن، ساعات کاری، امتیاز، شبکههای اجتماعی و لوکیشن.
|
||||||
|
|
||||||
|
برخلاف Doctor که آدرسها در entity جدا (`DoctorAddress`) بودند و نیاز به fetch جداگانه داشتند، **کلینیک آدرس/تلفن/مختصات جغرافیایی را مستقیم روی خودش دارد** — پس نیازی به endpoint اضافه نیست.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
Schema.org فعلی کلینیک بسیار ناقص است:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// وضعیت فعلی — فقط name و image
|
||||||
|
const jsonLd = clinic ? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "MedicalClinic",
|
||||||
|
name: clinic.title,
|
||||||
|
...(clinic.images_clinic?.[0]?.url && { image: imageUrl(clinic.images_clinic[0].url) }),
|
||||||
|
} : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
موارد غایب:
|
||||||
|
- `twitter:card` در `generateMetadata`
|
||||||
|
- `telephone`, `address` (PostalAddress)
|
||||||
|
- `geo` (GeoCoordinates) از `latitude`/`longitude`
|
||||||
|
- `openingHours` / `openingHoursSpecification` از `is247`
|
||||||
|
- `aggregateRating` از میانگین امتیاز پزشکان کلینیک
|
||||||
|
- `sameAs` از `social_media` (پس از اجرای پرامپت backend)
|
||||||
|
- `medicalSpecialty` از تخصص پزشکان
|
||||||
|
- `hasMap` لینک گوگل مپ
|
||||||
|
- `ImageObject` کامل به جای رشته ساده
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `app/clinic/[slug]/page.js` | صفحه اصلی — اینجا همه تغییرات اعمال میشود |
|
||||||
|
| `helper/index.js` | `imageUrl()` موجود است |
|
||||||
|
| `app/doctor/[slug]/page.js` | الگوی مرجع: `getDoctorAddresses`، `computeRating`، `sameAs`، `Review` |
|
||||||
|
|
||||||
|
## وضعیت فعلی API کلینیک
|
||||||
|
|
||||||
|
براساس `clinicpro/docs/api/clinic.md`، پاسخ `GET /api/v1/clinic/{uuid}` (داخل `data.data`) این فیلدها را دارد:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"uuid": "...",
|
||||||
|
"title": "کلینیک پارسیان",
|
||||||
|
"address": "یزد، خیابان امام خمینی، ...",
|
||||||
|
"telephone": "035-12345678",
|
||||||
|
"latitude": "31.8974",
|
||||||
|
"longitude": "54.3569",
|
||||||
|
"working_days": "شنبه تا چهارشنبه ۸ تا ۱۴",
|
||||||
|
"is247": false,
|
||||||
|
"images_clinic": [{ "url": "/uploads/clinic/...", "fid": 1 }],
|
||||||
|
"insurance": ["بیمه ایران", "تامین اجتماعی"],
|
||||||
|
"doctors": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
پس از اجرای پرامپت backend، فیلد `social_media` هم اضافه میشود:
|
||||||
|
```json
|
||||||
|
"social_media": {
|
||||||
|
"instagram": "https://instagram.com/clinic.example",
|
||||||
|
"telegram": null,
|
||||||
|
"aparat": null,
|
||||||
|
"youtube": null,
|
||||||
|
"linkedin": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
لیست پزشکان کلینیک از endpoint جداگانه (که قبلاً در صفحه fetch میشود):
|
||||||
|
```js
|
||||||
|
const reqDoctors = await fetchReq(`${API_URL}/api/v1/clinic/doctor-list/${slug}?page=${page}&limit=${limit}`);
|
||||||
|
const doctors = reqDoctors?.data?.data || [];
|
||||||
|
// هر doctor دارای: point (امتیاز عددی)، specialties (آرایه)، ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. بهبود `generateMetadata`
|
||||||
|
|
||||||
|
`twitter:card` اضافه شود و `openGraph.type` مشخص شود:
|
||||||
|
|
||||||
|
```js
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type: "website",
|
||||||
|
images: clinic.images_clinic?.[0]?.url
|
||||||
|
? [imageUrl(clinic.images_clinic[0].url)]
|
||||||
|
: ["https://www.nobat724.com/assets/images/logo.png"],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images: clinic.images_clinic?.[0]?.url
|
||||||
|
? [imageUrl(clinic.images_clinic[0].url)]
|
||||||
|
: ["https://www.nobat724.com/assets/images/logo.png"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. تابع کمکی `computeClinicRating`
|
||||||
|
|
||||||
|
این تابع را بالای کامپوننت `Clinic` (یا در `helper/index.js`) تعریف کن. از آرایه پزشکانی که قبلاً fetch میشوند استفاده میکند:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function computeClinicRating(doctors) {
|
||||||
|
const rated = doctors.filter((d) => d.point && Number(d.point) > 0);
|
||||||
|
if (rated.length === 0) return null;
|
||||||
|
const avg = rated.reduce((sum, d) => sum + Number(d.point), 0) / rated.length;
|
||||||
|
return {
|
||||||
|
"@type": "AggregateRating",
|
||||||
|
ratingValue: avg.toFixed(1),
|
||||||
|
ratingCount: rated.length,
|
||||||
|
bestRating: "5",
|
||||||
|
worstRating: "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳. ساخت JSON-LD کامل
|
||||||
|
|
||||||
|
`jsonLd` را در تابع `Clinic` جایگزین کن. از دادههایی که **قبلاً** در صفحه موجودند استفاده کن (`clinic` و `doctors`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const aggregateRating = computeClinicRating(doctors);
|
||||||
|
|
||||||
|
// تخصصهای یکتا از پزشکان کلینیک
|
||||||
|
const specialties = [
|
||||||
|
...new Set(
|
||||||
|
doctors.flatMap((d) => d.specialties?.map((s) => s.name) ?? [])
|
||||||
|
),
|
||||||
|
].slice(0, 5);
|
||||||
|
|
||||||
|
// sameAs از social_media (پس از اجرای پرامپت backend موجود میشود)
|
||||||
|
const sameAsLinks = clinic?.social_media
|
||||||
|
? Object.values(clinic.social_media).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const jsonLd = clinic
|
||||||
|
? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "MedicalClinic",
|
||||||
|
name: clinic.title,
|
||||||
|
|
||||||
|
// تصویر
|
||||||
|
...(clinic.images_clinic?.[0]?.url && {
|
||||||
|
image: {
|
||||||
|
"@type": "ImageObject",
|
||||||
|
url: imageUrl(clinic.images_clinic[0].url),
|
||||||
|
name: clinic.title,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// اطلاعات تماس و آدرس
|
||||||
|
...(clinic.telephone && { telephone: clinic.telephone }),
|
||||||
|
...(clinic.address && {
|
||||||
|
address: {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
streetAddress: clinic.address,
|
||||||
|
addressCountry: "IR",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// مختصات جغرافیایی
|
||||||
|
...(clinic.latitude && clinic.longitude && {
|
||||||
|
geo: {
|
||||||
|
"@type": "GeoCoordinates",
|
||||||
|
latitude: clinic.latitude,
|
||||||
|
longitude: clinic.longitude,
|
||||||
|
},
|
||||||
|
hasMap: `https://maps.google.com/?q=${clinic.latitude},${clinic.longitude}`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ساعات کاری — فقط اگر ۲۴ ساعته باشد (working_days متن آزاد است)
|
||||||
|
...(clinic.is247 && {
|
||||||
|
openingHoursSpecification: {
|
||||||
|
"@type": "OpeningHoursSpecification",
|
||||||
|
dayOfWeek: [
|
||||||
|
"Monday", "Tuesday", "Wednesday", "Thursday",
|
||||||
|
"Friday", "Saturday", "Sunday",
|
||||||
|
],
|
||||||
|
opens: "00:00",
|
||||||
|
closes: "23:59",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// امتیاز
|
||||||
|
...(aggregateRating && { aggregateRating }),
|
||||||
|
|
||||||
|
// تخصصهای پزشکی
|
||||||
|
...(specialties.length > 0 && {
|
||||||
|
medicalSpecialty: specialties,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// شبکههای اجتماعی
|
||||||
|
...(sameAsLinks.length > 0 && { sameAs: sameAsLinks }),
|
||||||
|
|
||||||
|
// URL صفحه
|
||||||
|
url: `https://www.nobat724.com/clinic/${clinic.uuid}`,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۴. بهروزرسانی Breadcrumb
|
||||||
|
|
||||||
|
Breadcrumb فعلی آیتم سوم بدون `item` دارد که اشتباه است. آیتم آخر (`position: 3`) نباید `item` داشته باشد (صفحه جاری است):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const breadcrumbJsonLd = clinic
|
||||||
|
? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 1,
|
||||||
|
name: "خانه",
|
||||||
|
item: "https://www.nobat724.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 2,
|
||||||
|
name: "کلینیکها",
|
||||||
|
item: "https://www.nobat724.com/clinics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 3,
|
||||||
|
name: clinic.title,
|
||||||
|
// بدون item — صفحه جاری
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **`computeClinicRating` فقط از `doctors` استفاده میکند** — این آرایه قبلاً در صفحه fetch شده و نیازی به endpoint جدید نیست.
|
||||||
|
- **`working_days` متن آزاد است** (مثلاً "شنبه تا چهارشنبه ۸ تا ۱۴") — نمیتوان آن را به `OpeningHoursSpecification` مپ کرد. فقط برای کلینیکهای `is247=true` این فیلد را ساختارمند اضافه کن.
|
||||||
|
- **`social_media` اختیاری** — اگر فیلد در API وجود نداشت (قبل از اجرای پرامپت backend)، `sameAsLinks` آرایه خالی میشود و `sameAs` به JSON-LD اضافه نمیشود.
|
||||||
|
- **`doctors` از `page=1&limit=50` فعلی** — برای محاسبه rating همین کافی است؛ endpoint جداگانهای اضافه نکن.
|
||||||
|
- **بعد از تغییر، `npm run build` اجرا کن** تا خطاهای TypeScript/ESLint آشکار شوند.
|
||||||
|
- **`DEV_MODE=TRUE` روی سرور dev** — برای بررسی JSON-LD از Google Rich Results Test یا `application/ld+json` در DevTools استفاده کن، نه از ایندکس واقعی.
|
||||||
|
- الگوی مرجع: `app/doctor/[slug]/page.js` — همان ساختار `sameAs`، `aggregateRating`، و `ImageObject` را پیروی کن.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# واحد پول = تومان در سایت عمومی (نمایش ÷۱۰ / ورودی ×۱۰) — API ریال میماند
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی). **cross-repo:** پرامپت همتا (ادمین): `clinicpro/.claude/prompt/currency-toman-display-admin.md`. API بکاند (`clinicpro`) واحدش **ریال** است و تغییر نمیکند؛ فیلدهای `*_rials` ریال برمیگردانند.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
واحد نمایشِ سایت باید **تومان** باشد، اما API همهٔ مبالغ را **ریال** میدهد و میگیرد (`appointment_fee_rials`، `amount_rials`، …) و ارسال به درگاه هم ریال است (بکاند مدیریت میکند). پس فقط باید در **UI** مقدار ریال ÷۱۰ و با «تومان» نشان داده شود، و اگر جایی مبلغ از کاربر گرفته میشود ×۱۰ به ریال تبدیل شود.
|
||||||
|
|
||||||
|
الان ناهماهنگ است: بعضی جاها عددِ `*_rials` خام با برچسب «ریال» نمایش داده میشود (`paying`, transactions, payment result, dashboard balance) و یکجا (`app/dashboard/page.js`) دستی `/10` شده. باید یکدست شود.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
همهٔ مبالغِ نمایشی در سایت به **تومان** (÷۱۰ از مقدار ریالِ API) با برچسب «تومان» نشان داده شوند؛ منطق پرداخت/ارسال به درگاه (سمت بکاند، ریال) دست نخورد.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `nobat724_front/lib/money.js` | **جدید** — helper تبدیل/فرمت مرکزی |
|
||||||
|
| `nobat724_front/components/appointment/paying/index.js` | مبلغ نوبت + دکمهٔ پرداخت (ریال→تومان) |
|
||||||
|
| `nobat724_front/components/dashboard/userAccount/sidebars/transactions/Card.js` | مبلغ تراکنش |
|
||||||
|
| `nobat724_front/components/dashboard/userAccount/sidebars/transactions/List.js` | مبلغ تراکنش |
|
||||||
|
| `nobat724_front/components/dashboard/userAccount/Head.js` | برچسب/موجودی |
|
||||||
|
| `nobat724_front/app/payment/[uuid]/page.js` | مبلغ نتیجهٔ پرداخت |
|
||||||
|
| `nobat724_front/app/dashboard/page.js` | جمع تراکنشها (حذف `/10` دستی، استفاده از helper) |
|
||||||
|
|
||||||
|
> با `grep -rn "ریال\|amount_rials\|_rials\|toLocaleString" components app` بقیهٔ نقاط مبلغ را هم پیدا و یکدست کن.
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
### `components/appointment/paying/index.js`
|
||||||
|
```js
|
||||||
|
setFeeRials(Number(res?.data?.appointment_fee_rials) || 0);
|
||||||
|
// ...
|
||||||
|
const rials = feeRials != null ? feeRials.toLocaleString("fa-IR") : "—";
|
||||||
|
// ...
|
||||||
|
{rials} <span className="...">ریال</span>
|
||||||
|
// ...
|
||||||
|
{testMode ? "پرداخت آزمایشی" : `پرداخت ${rials} ریال`}
|
||||||
|
```
|
||||||
|
مقدارِ ریالِ خام را با «ریال» نشان میدهد (باید تومان).
|
||||||
|
|
||||||
|
### `app/dashboard/page.js` (تبدیل دستی ناهماهنگ)
|
||||||
|
```js
|
||||||
|
const totalTransactions = Math.round(totalRials / 10); // ریال → تومان
|
||||||
|
```
|
||||||
|
|
||||||
|
### `app/payment/[uuid]/page.js`
|
||||||
|
```js
|
||||||
|
{formatAmount(payment.amount_rials)} ریال
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. helper مرکزی `lib/money.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const RIAL_PER_TOMAN = 10;
|
||||||
|
export const rialToToman = (rial) => Math.round((Number(rial) || 0) / RIAL_PER_TOMAN);
|
||||||
|
export const tomanToRial = (toman) => Math.round((Number(toman) || 0) * RIAL_PER_TOMAN);
|
||||||
|
// فرمت فارسیِ مبلغِ تومان از مقدار ریالِ API
|
||||||
|
export const formatToman = (rial) => rialToToman(rial).toLocaleString("fa-IR");
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. `paying/index.js`
|
||||||
|
|
||||||
|
- نمایش مبلغ: `const toman = feeRials != null ? formatToman(feeRials) : "—";`
|
||||||
|
- جای «ریال» → «تومان»؛ دکمه: `پرداخت ${toman} تومان`.
|
||||||
|
- `feeRials` را همچنان از `appointment_fee_rials` بگیر (ریال)؛ فقط نمایش تبدیل شود. **مبلغی که برای شروع پرداخت به بکاند/درگاه میرود تغییر نکند** (بکاند ریال میفرستد).
|
||||||
|
|
||||||
|
### ۳. تراکنشها و موجودی داشبورد
|
||||||
|
|
||||||
|
- `transactions/Card.js` و `List.js`: `Number(data.amount_rials)` خام + «ریال» → `formatToman(data.amount_rials)` + «تومان».
|
||||||
|
- `userAccount/Head.js`: موجودی/برچسب «ریال» → تومان (اگر عددی خام کنارش هست با `formatToman`).
|
||||||
|
- `app/dashboard/page.js`: بهجای `Math.round(totalRials/10)` از `rialToToman(totalRials)` استفاده کن (همان نتیجه، یکدست).
|
||||||
|
|
||||||
|
### ۴. صفحهٔ نتیجهٔ پرداخت
|
||||||
|
|
||||||
|
- `app/payment/[uuid]/page.js`: `{formatAmount(payment.amount_rials)} ریال` → `{formatToman(payment.amount_rials)} تومان` (یا `formatAmount` را با `rialToToman` هماهنگ کن؛ اگر `formatAmount` جای دیگری هم استفاده میشود، دوبارهتبدیل نشود).
|
||||||
|
|
||||||
|
### ۵. جستجوی باقیماندهٔ «ریال»
|
||||||
|
|
||||||
|
`grep -rn "ریال" components app` → هر مبلغِ خام ریال را `formatToman` + «تومان» کن. برچسبهای ثابت «ریال» به «تومان».
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **API/درگاه ریال میماند:** هیچ فراخوانی `services/response.js` و هیچ مقدارِ ارسالی به بکاند تغییر نمیکند؛ فقط نمایش. مبلغ پرداخت را بکاند (ریال) مدیریت میکند.
|
||||||
|
- **ورودی پول (اگر هست):** اگر جایی کاربر مبلغ وارد میکند که به API میرود، با `tomanToRial` به ریال تبدیل کن (در این سایت معمولاً ورودی پول نیست؛ بیشتر نمایش است).
|
||||||
|
- **double-convert نکن:** جایی که قبلاً `/10` شده (dashboard/page.js) را با helper جایگزین کن، نه اضافه.
|
||||||
|
- **RTL/فونت/Jalali** مثل قبل؛ فقط عدد و برچسب واحد عوض میشود.
|
||||||
|
- تست: `npm run lint` و `npm run build` بدون خطا؛ سپس چشمی صفحهٔ پرداخت نوبت، داشبورد تراکنشها، و نتیجهٔ پرداخت — اعداد باید ۱۰ برابر کوچکتر و با «تومان» باشند.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# اتصال دقیق همهی تبهای داشبورد کاربر به دادهی واقعی backend
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — سایت عمومی. **frontend-only**؛ همهی endpointهای لازم در backend موجودند (پروفایل، نوبتهای کاربر، پرداختها). تبهایی که backend ندارند (نظرات/پیامها) باید empty-state درست نشان دهند، نه دادهی جعلی.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
`/dashboard` شش تب دارد (`components/dashboard/userAccount/index.js`):
|
||||||
|
۱. **حساب کاربری** (DetailUser → اطلاعات عمومی + سوابق پزشکی: بیماریها/آلرژی/داروها/جراحی/سابقهخانوادگی/بستگان)
|
||||||
|
۲. **نوبتهای من** (Turns)
|
||||||
|
۳. **تراکنشهای من** (Transactions)
|
||||||
|
۴. **نظرات من** (Comments)
|
||||||
|
۵. **پیامها** (Messages)
|
||||||
|
۶. **خروج**
|
||||||
|
|
||||||
|
وضعیت فعلی بعد از رفعهای اخیر:
|
||||||
|
- نوبتها → `GET /api/v1/appointments/user` (وصل شده)
|
||||||
|
- تراکنشها → `GET /api/v1/my/payments` (وصل شده)
|
||||||
|
- حساب کاربری → `getUserProfile(userUuid)` لود میشود ولی **مپینگ فیلدها چند ایراد دارد** (بیمه، سوابق پزشکی) و کامل round-trip نمیشود.
|
||||||
|
- نظرات/پیامها → از `user.comments`/`user.messages` که `buildPatientUser` همیشه `[]` میگذارد؛ **هیچ endpoint کاربری برای این دو در backend نیست** (تأییدشده: فقط `/comments/{doctorUuid}` و admin؛ messages اصلاً نیست).
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
۱. تب **حساب کاربری** را درست لود و ذخیره کن: مپینگ صحیح بیمه و سوابق پزشکی (`other`)، استفاده از `uuid` پروفایل برای PATCH.
|
||||||
|
۲. تبهای **نوبتها/تراکنشها** را تأیید کن (شکل پاسخ، empty-state، تاریخ شمسی، مبلغ).
|
||||||
|
۳. تبهای **نظرات/پیامها**: چون backend ندارند، empty-state تمیز نشان بده (نه crash، نه دادهی ساختگی). در گزارش ذکر کن که نیاز به endpoint backend دارند (cross-repo آینده).
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/dashboard/userAccount/detailUser/information/index.js` | لود پروفایل برای فرم اطلاعات + سوابق |
|
||||||
|
| `components/dashboard/userAccount/detailUser/index.js` | PATCH ذخیره (`patchUserProfile(usedKeys, information?.uuid)`) |
|
||||||
|
| `components/dashboard/userAccount/detailUser/function.js` | `setNewData` (تغییر سوابق پزشکی) |
|
||||||
|
| `helper/index.js` | `removeAdditionalKeysDashboard`, `changeDateType` |
|
||||||
|
| `components/dashboard/userAccount/sidebars/turns/index.js` | نوبتها (وصلشده — تأیید) |
|
||||||
|
| `components/dashboard/userAccount/sidebars/transactions/index.js` | تراکنشها (وصلشده — تأیید) |
|
||||||
|
| `components/dashboard/userAccount/sidebars/comments/index.js` | `user.comments` |
|
||||||
|
| `components/dashboard/userAccount/sidebars/messages/index.js` | `user.messages` |
|
||||||
|
| `lib/representationAdapters.js` | `buildPatientUser` |
|
||||||
|
| `clinicpro/docs/api/user-profile.md` | قرارداد پروفایل + فیلد `other` |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
### مپینگ غلط بیمه در `information/index.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const profile = res?.data?.data;
|
||||||
|
// ...
|
||||||
|
let changedData = { ...profile };
|
||||||
|
changedData.basic_insurance = [Number(profile?.basic_insurance?.id)]; // ❌ backend فیلد basic_insurance_id (عدد) میدهد، نه object با .id → [NaN]
|
||||||
|
changedData.prev_data = true;
|
||||||
|
changedData = changeDateType(changedData, false);
|
||||||
|
setInformation(changedData);
|
||||||
|
```
|
||||||
|
|
||||||
|
> پاسخ پروفایل backend (از `toArray`): `basic_insurance_id`, `supplementary_insurance_id` (اعداد یا null)، نه `basic_insurance.id`. همچنین سوابق پزشکی در `other` است (`{ disease, allergies, medications, surgeries, family_history, relatives }`).
|
||||||
|
|
||||||
|
### قرارداد backend (از `user-profile.md` و entity `toArray`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "...", "user_uuid": "...",
|
||||||
|
"label": "...", "family": "...", "national_code": "...", "gender": "male",
|
||||||
|
"date_of_birth": null, "fathers_name": null, "blood_type": null,
|
||||||
|
"marital_status": null, "education": null, "job": null, "address": null,
|
||||||
|
"home_phone": null, "work_phone": null,
|
||||||
|
"insurance_id": null, "basic_insurance_id": 1, "supplementary_insurance_id": null,
|
||||||
|
"other": { "disease": [...], "allergies": [...], "medications": [...], "surgeries": [...], "family_history": [...], "relatives": [...] },
|
||||||
|
"description": null, "sharing_with_user": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> پاسخ دوبار تودرتو است → `res.data.data`.
|
||||||
|
|
||||||
|
### ذخیره در `detailUser/index.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const updateData = () => {
|
||||||
|
const usedKays = removeAdditionalKeysDashboard(information);
|
||||||
|
request.patchUserProfile(usedKays, information?.uuid, Cookies.get("access_token")) // arg سوم نادیده گرفته میشود
|
||||||
|
.then(...).catch(...);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. مپینگ درست پروفایل هنگام لود (`information/index.js`)
|
||||||
|
|
||||||
|
- بیمه را از فیلد درست بخوان (نه `.id`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
let changedData = { ...profile };
|
||||||
|
changedData.basic_insurance = profile?.basic_insurance_id ? [profile.basic_insurance_id] : [];
|
||||||
|
changedData.supplementary_insurance = profile?.supplementary_insurance_id ? [profile.supplementary_insurance_id] : [];
|
||||||
|
changedData.other = profile?.other ?? { disease, allergies: [], medications: [], surgeries: [], family_history: [], relatives: [] };
|
||||||
|
changedData.prev_data = true;
|
||||||
|
changedData = changeDateType(changedData, false);
|
||||||
|
setInformation(changedData);
|
||||||
|
```
|
||||||
|
|
||||||
|
- اطمینان حاصل کن `changedData.uuid = profile.uuid` (uuid پروفایل) حفظ میشود تا `patchUserProfile(..., information.uuid)` درست کار کند.
|
||||||
|
- `hasProfileData` فعلی (`label || family || national_code`) قابلقبول است؛ ولی چون backend حالا پروفایل خالی lazy-create میکند، پروفایل همیشه `uuid` دارد — برای تشخیص «پر بودن» همان منطق فیلد محتوا را نگهدار، ولی `uuid` را حتی در حالت خالی هم در `information` بگذار تا PATCH کار کند:
|
||||||
|
|
||||||
|
```js
|
||||||
|
} else {
|
||||||
|
setInformation({
|
||||||
|
uuid: profile?.uuid, // ✅ تا ذخیره ممکن باشد
|
||||||
|
prev_data: false,
|
||||||
|
other: { disease, allergies: [], medications: [], surgeries: [], family_history: [], relatives: [] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. تأیید ذخیره (سوابق پزشکی → `other`)
|
||||||
|
|
||||||
|
- `removeAdditionalKeysDashboard(information)` باید payloadی بسازد که شامل `other` (سوابق) و فیلدهای پروفایل باشد و کلیدهای اضافی frontend (مثل `prev_data`) را حذف کند. بررسی کن خروجی با آنچه backend `hydrate` میپذیرد همخوان است (`name`/`label`, `family`, `national_code`, `gender`, `basic_insurance`, `supplementary_insurance`, `other`, ...).
|
||||||
|
- بعد از PATCH موفق، `information.uuid` را از پاسخ بهروز نگهدار (اگر backend پروفایل جدید برگرداند).
|
||||||
|
- arg سوم `patchUserProfile` (access_token) زائد است (interceptor خودش توکن میزند) — حذفش کن یا بگذار (بیاثر). ترجیحاً امضای تابع را تمیز نگهدار.
|
||||||
|
|
||||||
|
### ۳. تأیید تبهای نوبتها و تراکنشها
|
||||||
|
|
||||||
|
- **نوبتها** (`turns/index.js`): پاسخ `appointments/user` دوبار تودرتو است → `res.data.data` (آرایه). تأیید کن کارتها (`turns/Card.js`, `List.js`) فیلدهای واقعی نوبت (`slot_start`, `status`, `doctor`, `patient_name`) را میخوانند؛ تاریخ شمسی با `moment-jalaali`، وضعیتها به فارسی map شوند. اگر کارت فیلدهای mock قدیمی میخواند، با شکل واقعی `Appointment.toArray()` همخوان کن.
|
||||||
|
- **تراکنشها** (`transactions/index.js`): پاسخ `my/payments` paginated است → `res.data` آرایه، `res.meta.totalPages`. تأیید کن `Card.js`/`List.js` فیلدهای واقعی پرداخت (`amount_rials`, `status`, `gateway`, `type`, `created_at`) را میخوانند؛ مبلغ ریال→تومان با جداکنندهی فارسی، وضعیتها فارسی.
|
||||||
|
- **empty-state:** اگر آرایه خالی بود، پیام «موردی یافت نشد» (نه crash، نه اسپینر بیپایان).
|
||||||
|
|
||||||
|
### ۴. تبهای نظرات و پیامها (بدون backend)
|
||||||
|
|
||||||
|
- چون endpoint کاربری وجود ندارد، `Comments`/`Messages` از `user.comments`/`user.messages` (همیشه `[]`) میخوانند → empty-state تمیز نشان بده: «نظری ثبت نکردهاید» / «پیامی ندارید». مطمئن شو روی آرایهی خالی crash نمیکنند (`user.comments?.map`، گارد طول).
|
||||||
|
- **دادهی جعلی نساز.** در گزارش پایانی ذکر کن که این دو تب برای دادهی واقعی نیاز به endpoint backend دارند (پرامپت cross-repo جداگانه در آینده).
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **هیچ تغییر backend در این پرامپت نیست.** پروفایل/نوبت/پرداخت همه آمادهاند؛ نظرات/پیامها عمداً empty میمانند.
|
||||||
|
- **double-nesting:** `user-profile` و `appointments/user` → `res.data.data`؛ `my/payments` → paginated (`res.data` + `res.meta`). هرکدام را درست مصرف کن (با pitfallهای قبلی یکدست).
|
||||||
|
- **uuid پروفایل برای PATCH:** `information.uuid` باید uuid پروفایل باشد (از `res.data.data.uuid`)، نه user-uuid.
|
||||||
|
- **سوابق پزشکی = `other`:** همهی sub-tabها (allergies/medications/...) داخل `information.other` کار میکنند و با PATCH `other` ذخیره میشوند.
|
||||||
|
- **Jalali/RTL/مبلغ:** تاریخها شمسی، مبالغ ریال→تومان با جداکنندهی فارسی، timestampها یونیکس.
|
||||||
|
- **تست:** `npm run build`؛ سپس دستی با کاربر `09210651788` (پروفایل خالی + ۱ نوبت): تب حساب کاربری فرم خالی قابلویرایش و ذخیره؛ تب نوبتها ۱ نوبت با تاریخ/وضعیت درست؛ تب تراکنشها empty-state؛ نظرات/پیامها empty-state بدون crash. سپس commit per-tab.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# رفع نمایش نقشه صفحه پزشک + بهبود مودال claim (کپچا/موبایل) + حذف پروفایل توسط مالک
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی)
|
||||||
|
> پرامپت همتا (backend اول): `clinicpro/.claude/prompt/doctor-map-claim-captcha-delete.md` — کپچا و فیلد mobile روی endpoint claim، و اجازهٔ حذف پروفایل به مالک. این پرامپت آن قرارداد را مصرف میکند.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
سه موضوع در صفحهٔ پزشک سایت:
|
||||||
|
1. نقشه در صفحهٔ پزشک درست نمایش داده نمیشود (مثال: `/doctor/ab747d75-2114-42b8-9e6d-abdaa338edbe`).
|
||||||
|
2. مودال «تأیید و مدیریت پروفایل» (claim) از قبل هست ولی طبق سناریو باید متن کادر اطلاعرسانی بهروز شود، **فیلد موبایل** و **کپچای ALTCHA** اضافه شود.
|
||||||
|
3. پس از مالکشدن، پزشک باید دکمهٔ **حذف پروفایل** داشته باشد.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `app/doctor/[slug]/page.js` | فچ `doctor` + `getDoctorAddresses(doctor.id)` (خط ۲۴، ۱۰۵) |
|
||||||
|
| `components/doctor/detailDoctor/cards/locations/index.js` | کارت آدرسها — از `doctor?.address` میخواند (خط ۱۱) |
|
||||||
|
| `components/doctor/detailDoctor/cards/locations/Item.js` | رندر iframe گوگل با `data.map.latitude/longitude` (خط ۴۶، ۶۵) |
|
||||||
|
| `components/doctor/claim/index.js` | `ClaimProfileSection` موجود (سکشن + مودال) |
|
||||||
|
| `components/Altcha.js` | کامپوننت ALTCHA موجود سایت |
|
||||||
|
| `services/response.js` | `getDoctorClaimInfo` / `postDoctorClaim` موجود |
|
||||||
|
|
||||||
|
## وظیفه ۱ — رفع نمایش نقشه صفحه پزشک
|
||||||
|
|
||||||
|
### ریشه (تأییدشده)
|
||||||
|
|
||||||
|
کارت آدرسها از **`doctor?.address`** میخواند:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// components/doctor/detailDoctor/cards/locations/index.js:11
|
||||||
|
{doctor?.address?.map((item, idx) => ( <Item data={item} /> ))}
|
||||||
|
```
|
||||||
|
|
||||||
|
اما مختصات نقشه در پاسخِ **جداگانه**ی `GET /api/v1/clinic-pro/doctor-addresses/{id}` است (backend آن را با شکل `map: { latitude, longitude }` برمیگرداند). در صفحه، این پاسخ در متغیر `addresses` فچ میشود و فقط در **JSON-LD** استفاده شده (`app/doctor/[slug]/page.js:105,141`)، ولی به کارت دیداری آدرسها **پاس داده نمیشود**. پس `doctor.address` یا خالی است یا `map` ندارد → `Item` شرط `data?.map?.latitude` را رد میکند → نقشه هرگز نمایش داده نمیشود.
|
||||||
|
|
||||||
|
### راهحل
|
||||||
|
|
||||||
|
`addresses` (که `map.latitude/longitude` دارد) را به همان کارتی که نقشه را رندر میکند برسان:
|
||||||
|
|
||||||
|
- در `app/doctor/[slug]/page.js`، `addresses` را به `DoctorPage`/`detailDoctor` پاس بده (prop جدید یا ادغام در `doctor.address`).
|
||||||
|
- در `locations/index.js`، بهجای `doctor?.address` از همان آرایهٔ `addresses` استفاده کن که هر آیتم `map: { latitude, longitude }` و `address`/`telephone` دارد.
|
||||||
|
- شکل مصرفی `Item` (`data.map.latitude`, `data.map.longitude`, `data.address`, `data.telephone`) را با شکل خروجی `doctor-addresses` همتراز کن (backend همین کلیدها را میدهد — تأیید در `clinicpro/src/Doctor/Controller/DoctorController.php:166`).
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// locations/index.js — نمونه
|
||||||
|
export default function Locations({ addresses }) {
|
||||||
|
if (!addresses?.length) return null;
|
||||||
|
return (<>{addresses.map((item, idx) => <Item key={idx} data={item} />)}</>);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **edge:** پزشکِ ایمپورتشده (مثل نمونهٔ کاربر) آدرس ندارد → آرایه خالی → کارت اصلاً رندر نشود (نه نقشهٔ خراب). این درست است.
|
||||||
|
- **edge:** آدرسِ بدون مختصات (`latitude=null`) → دکمهٔ مسیریابی/iframe نمایش داده نشود، ولی خود آدرس/تلفن نمایش داده شود.
|
||||||
|
|
||||||
|
## وظیفه ۲ — بهبود مودال claim (متن، موبایل، کپچا)
|
||||||
|
|
||||||
|
`components/doctor/claim/index.js` از قبل سکشن + مودال دارد. تغییرات:
|
||||||
|
|
||||||
|
**الف) متن کادر اطلاعرسانی** (طبق سناریو):
|
||||||
|
|
||||||
|
> «این پروفایل بر اساس اطلاعات عمومی سازمان نظام پزشکی ایجاد شده است و هنوز توسط پزشک تأیید و مدیریت نمیشود. نوبتهای این پروفایل عمومی و غیرخاص هستند.
|
||||||
|
> آیا شما این پزشک هستید؟» + دکمهٔ «تأیید و مدیریت این پروفایل».
|
||||||
|
|
||||||
|
**ب) فیلد موبایل در فرم:** علاوه بر نام/نامخانوادگی/کد ملی/تاریخ تولد، فیلد **شماره موبایل** اضافه شود (پیشپرشده از کاربر لاگینشده اگر در دسترس است). به بدنهٔ `postDoctorClaim` اضافه شود:
|
||||||
|
|
||||||
|
```js
|
||||||
|
await request.postDoctorClaim(doctor.uuid, {
|
||||||
|
national_code, birth_date, first_name, last_name, mobile, altcha, // ← mobile و altcha جدید
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**ج) کپچای ALTCHA:** از کامپوننت موجود `components/Altcha.js` استفاده کن. تا وقتی کاربر کپچا را حل نکرده، دکمهٔ ارسال **غیرفعال** بماند:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import Altcha from "@/components/Altcha";
|
||||||
|
// ...
|
||||||
|
const [altcha, setAltcha] = useState("");
|
||||||
|
// در فرم:
|
||||||
|
<Altcha onVerified={setAltcha} />
|
||||||
|
<Button disabled={loading || !altcha} onClick={submit}>تأیید هویت و تصاحب پروفایل</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
- payload کپچا را در بدنهٔ claim بفرست (backend همتا `CaptchaGuard::assertValid` را چک میکند). نام فیلد را با آنچه `CaptchaGuard` انتظار دارد هماهنگ کن (بررسی `AuthController` سایت/بکاند — معمولاً `altcha`).
|
||||||
|
- اگر `ALTCHA_ENABLED=false` (dev) بکاند کپچا را نادیده میگیرد؛ ولی UI کپچا را نشان بده تا در prod کار کند.
|
||||||
|
|
||||||
|
**stateهای موجود مودال** (loading/error/success/double-submit/پیام خوشآمد) حفظ شوند؛ فقط فیلدها و کپچا اضافه میشوند. خطای `ERR_CAPTCHA_001` از بکاند → پیام «تأیید امنیتی ناموفق بود، دوباره تلاش کنید».
|
||||||
|
|
||||||
|
## وظیفه ۳ — حذف پروفایل توسط مالک
|
||||||
|
|
||||||
|
پس از claim موفق (پزشک مالک شد)، در صفحهٔ مدیریت پروفایل پزشک (یا همان صفحهٔ پزشک وقتی کاربرِ لاگینشده مالک است) دکمهٔ **«حذف پروفایل»** نمایش داده شود.
|
||||||
|
|
||||||
|
- فقط وقتی نمایش داده شود که کاربرِ لاگینشده مالکِ `claimed` این پروفایل باشد (از `owner_status` + تطبیق کاربر). مرجع نهایی مجوز، backend است.
|
||||||
|
- کلیک → **دیالوگ تأیید** با پیام هشدار (طبق سناریو: «قبل از حذف، پیام هشدار نمایش داده شود»)، سپس:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// متد جدید در services/response.js
|
||||||
|
deleteDoctor: (uuid) => api.delete(`api/v1/doctor/${uuid}`, { requireAuth: true }),
|
||||||
|
```
|
||||||
|
|
||||||
|
- backend اجازهٔ حذف مالک را میدهد (پرامپت همتا). خطاها: ۴۰۳ (مالک نیست)، ۴۰۹ (پزشک نوبت ثبتشده دارد) → پیام فارسی مناسب.
|
||||||
|
- پس از حذف موفق → هدایت به صفحهٔ اصلی/پنل + toast موفقیت.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **backend اول اجرا شود** (کپچا + فیلد mobile + delete مالک) وگرنه این تغییرات ۴۲۲/۴۰۳ میگیرند.
|
||||||
|
- multi-domain: مودال claim کامپوننت مشترک است و روی همهٔ دامنهها/زیردامنهها کار میکند؛ منطق را per-domain تکرار نکن.
|
||||||
|
- RTL، فارسی، Vazir، date-picker شمسی موجود؛ کتابخانهٔ جدید اضافه نکن.
|
||||||
|
- هیچ درخواستی از فرانت به API سازمان (شاهکار/ثبتاحوال) نرود؛ همه backend.
|
||||||
|
- تست:
|
||||||
|
```bash
|
||||||
|
cd nobat724_front && npm run lint && npm run build
|
||||||
|
# صفحهٔ پزشکِ دارای آدرس با مختصات → نقشه نمایش داده شود؛
|
||||||
|
# پزشک ایمپورتشدهٔ بدون آدرس → کارت نقشه رندر نشود (نه خراب)؛
|
||||||
|
# مودال claim → کپچا اجباری، فیلد موبایل، ارسال موفق؛ دکمهٔ حذف فقط برای مالک.
|
||||||
|
```
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
# نمایش چندتخصصی پزشک در کارت، فیلترها و پوستر
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — سایت عمومی نوبتدهی.
|
||||||
|
|
||||||
|
**cross-repo.** پرامپت همتا: `clinicpro/.claude/prompt/doctor-multi-specialty-search.md`.
|
||||||
|
**آن را اول اجرا کن.** وظیفهٔ ۳ اینجا به کلید `parent_id` در `specialties[]` پاسخ
|
||||||
|
`GET /api/v1/doctors` نیاز دارد که همانجا اضافه میشود.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
هر پزشک چند تخصص دارد و ساختار دو سطحی والد/فرزند است. نمونهٔ واقعی — دکتر محمدباقر جهانتاب
|
||||||
|
شش تخصص دارد:
|
||||||
|
|
||||||
|
```
|
||||||
|
13 جراحی عمومی (والد)
|
||||||
|
14 جراحی پلاستیک و زیبایی
|
||||||
|
167 جراحی لاپاراسکوپی
|
||||||
|
168 جراح تیروئید
|
||||||
|
169 جراح گوارش
|
||||||
|
170 جراحی سرطانها
|
||||||
|
```
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
چهار چیز در سایت عمومی میشکند.
|
||||||
|
|
||||||
|
### ۱. `data/specialties.json` کهنه است — ریشهٔ بقیهٔ مشکلات
|
||||||
|
|
||||||
|
فایل ثابت است و با دیتابیس همگام نیست:
|
||||||
|
|
||||||
|
- دیتابیس ۹۹ تخصص دارد، فایل ۹۴ تا.
|
||||||
|
- پنج فرزندِ `جراحی عمومی` با شناسههای ۱۶۷ تا ۱۷۱ در فایل **نیستند** — دقیقاً همانهایی که
|
||||||
|
این پزشک دارد.
|
||||||
|
|
||||||
|
این فایل در ۹ جا استفاده میشود: `app/sitemap.js`، `app/specialties/[slug]/page.js`،
|
||||||
|
`app/doctor/[slug]/page.js`، `components/home/search/Fields.js`، `components/specialties/index.js`،
|
||||||
|
`components/specialties/list/ItemSpecialties.js`، `components/doctor/index.js`،
|
||||||
|
`components/clinics/index.js`، `helper/index.js`.
|
||||||
|
|
||||||
|
نتیجه: `/specialties/جراح-گوارش` برابر ۴۰۴ است، لینک breadcrumb صفحهٔ پزشک میشکند، و
|
||||||
|
تخصص در سایتمپ نیست. تا این حل نشود، ساختن UI والد/فرزند ممکن نیست.
|
||||||
|
|
||||||
|
### ۲. انتخاب گروه، بیصدا به اولین زیرتخصص محدود میشود
|
||||||
|
|
||||||
|
کاربر `جراحی عمومی` را میزند و بدون اینکه بداند، فیلتر روی `جراحی پلاستیک و زیبایی` مینشیند.
|
||||||
|
هیچ گزینهای برای «کل گروه» وجود ندارد.
|
||||||
|
|
||||||
|
### ۳. کارت پزشک همهٔ تخصصها را پشتسرهم چاپ میکند
|
||||||
|
|
||||||
|
در موبایل ارتفاع کارت باد میکند و شبکه بههم میریزد.
|
||||||
|
|
||||||
|
### ۴. پوستر سرریز میکند
|
||||||
|
|
||||||
|
چهار چیپ اول با نامهای بلند، به سه ردیف میروند، بخش hero بلند میشود و بخشهای پایین از
|
||||||
|
کادر ثابت `1080×1350` با `overflow-hidden` بیرون میزنند.
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
|
||||||
|
- ✅ موفق: بعد از `npm run build`، فایل `data/specialties.json` هر ۹۹ تخصص فعال را با
|
||||||
|
`parent_id` و `slug` دارد و `/specialties/جراح-گوارش` صفحه میدهد نه ۴۰۴.
|
||||||
|
- ✅ موفق: در مودال فیلترها، انتخاب گروه `جراحی عمومی` گزینهٔ پیشفرض
|
||||||
|
«همه تخصصهای جراحی عمومی» را میگذارد و درخواست با `specialty_id=13` میرود.
|
||||||
|
- ✅ موفق: کارت دکتر جهانتاب در موبایل و دسکتاپ فقط `جراحی عمومی +5` نشان میدهد و ارتفاعش با
|
||||||
|
کارت پزشک تکتخصصی یکی است.
|
||||||
|
- ✅ موفق: کلیک روی `+5` فهرست کامل شش تخصص را در Popover نشان میدهد و **صفحهٔ پزشک را باز نمیکند**.
|
||||||
|
- ✅ موفق: پوستر همان پزشک، هیچ محتوایی بیرون از کادر ۱۳۵۰ ندارد و تخصصها روی هم نمیافتند.
|
||||||
|
- ❌ خطا: پزشک بدون هیچ تخصص → کارت بدون بخش تخصص و بدون `+0`، نه `undefined` و نه کرش.
|
||||||
|
- ❌ خطا: اسکریپت همگامسازی وقتی API در دسترس نیست → build با پیام روشن شکست بخورد و فایل
|
||||||
|
موجود را با آرایهٔ خالی بازنویسی **نکند**.
|
||||||
|
- ⚠️ مرزی: پزشک با دقیقاً یک تخصص → فقط نام، بدون چیپ `+N`.
|
||||||
|
- ⚠️ مرزی: پزشک با دو تخصص ریشهٔ متفاوت (مثلاً `جراحی عمومی` و `داخلی`) → اولین ریشه نمایش،
|
||||||
|
بقیه در `+N`.
|
||||||
|
- ⚠️ مرزی: پزشکی که فقط زیرتخصص دارد و هیچ ریشهای ندارد → اولین تخصص آرایه نمایش داده شود.
|
||||||
|
- ⚠️ مرزی: پوستر پزشکی با نام تخصص خیلی بلند → بریدن روی مرز کلمه، نه وسط کلمه.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `scripts/sync-specialties.mjs` | **جدید** — ساخت `data/specialties.json` از API |
|
||||||
|
| `package.json` | قلاب `prebuild` |
|
||||||
|
| `lib/specialtyDisplay.js` | **جدید** — قاعدهٔ «تخصص اصلی» مشترک بین کارت و پوستر |
|
||||||
|
| `helper/index.js` | `filterList()` خط ۲۷۰ |
|
||||||
|
| `components/doctors/modal/Content.js` | انتخاب اجباری اولین فرزند |
|
||||||
|
| `components/doctors/modal/form/index.js` | دو `CateSelector` |
|
||||||
|
| `app/component/ItemDoctor.js` | کارت پزشک |
|
||||||
|
| `app/component/PopoverDate.js` | الگوی موجود Popover — تقلید کن، از نو ننویس |
|
||||||
|
| `components/doctor/poster/index.js` | پوستر تیره |
|
||||||
|
| `components/doctor/poster/PosterLight.js` | پوستر روشن — همان باگ |
|
||||||
|
| `components/doctors/search/SearchField.js` | placeholder کادر جستجو |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
### انتخاب اجباری اولین زیرتخصص
|
||||||
|
|
||||||
|
```js
|
||||||
|
// components/doctors/modal/Content.js:5
|
||||||
|
const changeSpecialty = (name, value) => {
|
||||||
|
const newFilter = { ...filter, [name]: value };
|
||||||
|
|
||||||
|
if (name === "category") {
|
||||||
|
if (value) {
|
||||||
|
const filtered = specialties
|
||||||
|
.filter((item) => item.parent_id)
|
||||||
|
.filter((item) => String(item.parent_id) === String(value.id));
|
||||||
|
newFilter.specialty = filtered[0] || null;
|
||||||
|
} else {
|
||||||
|
newFilter.specialty = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilter(newFilter);
|
||||||
|
setDataInURL(newFilter);
|
||||||
|
return newFilter;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
و ساخت پارامتر، `specialty` را بر `category` ترجیح میدهد:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// helper/index.js:482
|
||||||
|
if (newFilter.specialty?.id) {
|
||||||
|
params.specialty_id = newFilter.specialty.id;
|
||||||
|
} else if (newFilter.category?.id) {
|
||||||
|
params.specialty_id = newFilter.category.id;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// helper/index.js:270
|
||||||
|
export const filterList = (data) => {
|
||||||
|
const parentList = specialties.filter((item) => !item.parent_id);
|
||||||
|
const childrenList = specialties.filter((item) => item.parent_id);
|
||||||
|
|
||||||
|
const filteredChildrenList =
|
||||||
|
data && data.category
|
||||||
|
? childrenList.filter(
|
||||||
|
(item) => String(item.parent_id) === String(data.category.id)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
parentList: parentList,
|
||||||
|
childrenList: filteredChildrenList,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### کارت — همه را پشتسرهم چاپ میکند
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// app/component/ItemDoctor.js:46
|
||||||
|
<TextLoading loading={loading} width={120} height={15}>
|
||||||
|
<p className="text-[#616161] text-[14px] font-normal">
|
||||||
|
تخصص:
|
||||||
|
{doctor?.specialties?.map(
|
||||||
|
(item, idx) =>
|
||||||
|
`${item.name} ${doctor.specialties.length === idx + 1 ? "" : "|"} `
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</TextLoading>
|
||||||
|
```
|
||||||
|
|
||||||
|
### پوستر — برش کور روی عدد ۴
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// components/doctor/poster/index.js:44 (و PosterLight.js:24 دقیقاً همین)
|
||||||
|
const specialties = (data?.specialties ?? []).filter((s) => s?.name).slice(0, 4);
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// components/doctor/poster/index.js:132
|
||||||
|
<div className="flex flex-wrap gap-[10px] mt-[16px]">
|
||||||
|
{specialties.map((s, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="flex items-center rounded-full bg-[#F59E0B]/18 border border-[#F59E0B]/40 px-[18px] py-[9px]"
|
||||||
|
>
|
||||||
|
<span className="text-[#FCD9A0] text-[22px] font-semibold leading-none">
|
||||||
|
{s.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
کادر ثابت است، پس هر ردیف اضافه محتوای پایین را بیرون میاندازد:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// components/doctor/poster/index.js:81
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="relative w-[1080px] h-[1350px] overflow-hidden flex flex-col p-[64px] text-right"
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. اسکریپت همگامسازی `data/specialties.json`
|
||||||
|
|
||||||
|
فایل جدید `scripts/sync-specialties.mjs`.
|
||||||
|
|
||||||
|
منبع: `GET ${NEXT_PUBLIC_API_URL}/api/v1/specialties` — **عمومی است و توکن نمیخواهد**.
|
||||||
|
بدون پارامتر `parent_id` همهٔ تخصصهای فعال را میدهد. پاسخ دولایه است:
|
||||||
|
`{ success, data: { data: [...] } }`.
|
||||||
|
|
||||||
|
هر آیتم دقیقاً شکل فایل فعلی را دارد: `id` و `uuid` و `name` و `slug` و `status` و `weight`
|
||||||
|
و `parent_id`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// scripts/sync-specialties.mjs
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
const OUT = new URL("../data/specialties.json", import.meta.url);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/api/v1/specialties`);
|
||||||
|
if (!res.ok) throw new Error(`specialties sync failed: HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
const items = json?.data?.data ?? [];
|
||||||
|
|
||||||
|
// آرایهٔ خالی یعنی چیزی غلط است — فایل موجود را با آن بازنویسی نکن.
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
throw new Error("specialties sync returned an empty list; refusing to overwrite");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
قبل از نوشتن، خروجی را با ترتیب پایدار مرتب کن (بر اساس `id`) تا diff فایل نویزی نشود،
|
||||||
|
و با دو فاصله و `\n` انتهایی بنویس تا با شکل فعلی فایل بخواند.
|
||||||
|
|
||||||
|
در `package.json` قلاب بزن:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"prebuild": "node scripts/sync-specialties.mjs",
|
||||||
|
```
|
||||||
|
|
||||||
|
`dev` را وصل نکن — توسعهٔ آفلاین نباید به API گره بخورد.
|
||||||
|
|
||||||
|
**نحوه تست:**
|
||||||
|
```bash
|
||||||
|
cd nobat724_front
|
||||||
|
node scripts/sync-specialties.mjs
|
||||||
|
python3 -c "import json;d=json.load(open('data/specialties.json'));print(len(d), [x['slug'] for x in d if x.get('parent_id')==13])"
|
||||||
|
```
|
||||||
|
باید ۹۹ و شامل `جراح-گوارش` باشد. سپس با `NEXT_PUBLIC_API_URL` غلط اجرا کن و مطمئن شو
|
||||||
|
خطا میدهد و فایل دستنخورده میماند.
|
||||||
|
بعد `npm run build` و باز کردن `/specialties/جراح-گوارش`.
|
||||||
|
|
||||||
|
### ۲. گزینهٔ «همه تخصصهای X»
|
||||||
|
|
||||||
|
`filterList` در `helper/index.js` یک آیتمِ «همه» به ابتدای `childrenList` اضافه کند که
|
||||||
|
شناسهاش همان شناسهٔ والد است:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const filteredChildrenList =
|
||||||
|
data && data.category
|
||||||
|
? [
|
||||||
|
// «همه» یعنی فیلتر روی خودِ گروه؛ بکاند specialty_id را به نوادگان گسترش میدهد.
|
||||||
|
{ id: data.category.id, name: `همه تخصصهای ${data.category.name}`, parent_id: null },
|
||||||
|
...childrenList.filter(
|
||||||
|
(item) => String(item.parent_id) === String(data.category.id)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
```
|
||||||
|
|
||||||
|
و در `Content.js` بهجای `filtered[0]`، همان آیتم «همه» انتخاب شود:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (name === "category") {
|
||||||
|
// پیشفرض «کل گروه» است، نه اولین زیرتخصص. انتخاب بیصدای اولین فرزند،
|
||||||
|
// جستجوی کاربر را بدون اطلاعش تنگ میکرد.
|
||||||
|
newFilter.specialty = value
|
||||||
|
? { id: value.id, name: `همه تخصصهای ${value.name}` }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`QueryForDoctorsReq` را دست نزن — چون شناسهٔ «همه» همان شناسهٔ والد است، همان مسیر فعلی
|
||||||
|
`specialty_id` را درست میفرستد.
|
||||||
|
|
||||||
|
**نحوه تست:** unit test در `helper/specialtyFilter.test.js` برای `filterList` —
|
||||||
|
با `category` برابر `جراحی عمومی` اولین آیتم `childrenList` باید `id` والد و عنوان
|
||||||
|
«همه تخصصهای جراحی عمومی» داشته باشد؛ بدون `category` آرایه خالی بماند.
|
||||||
|
سپس دستی: مودال فیلترها → گروه `جراحی عمومی` → در تب شبکه ببین `specialty_id=13` میرود
|
||||||
|
و دکتر جهانتاب در نتایج هست.
|
||||||
|
|
||||||
|
### ۳. قاعدهٔ مشترک «تخصص اصلی»
|
||||||
|
|
||||||
|
فایل جدید `lib/specialtyDisplay.js`. کارت و پوستر هر دو از این میخوانند تا قاعده دو جا
|
||||||
|
تکرار و واگرا نشود.
|
||||||
|
|
||||||
|
```js
|
||||||
|
/**
|
||||||
|
* تخصص «اصلی» و بقیه.
|
||||||
|
*
|
||||||
|
* ریشه (بدون parent_id) اصلی است چون عنوانی است که بیمار میشناسد و هنگام ذخیره در
|
||||||
|
* بکاند خودکار به پزشک اضافه میشود، پس تقریباً همیشه وجود دارد. اگر ریشهای نبود،
|
||||||
|
* اولین آیتم آرایه.
|
||||||
|
*/
|
||||||
|
export function splitSpecialties(list) {
|
||||||
|
const items = (list ?? []).filter((s) => s?.name);
|
||||||
|
if (items.length === 0) return { primary: null, rest: [] };
|
||||||
|
|
||||||
|
const primary = items.find((s) => !s.parent_id) ?? items[0];
|
||||||
|
|
||||||
|
return { primary, rest: items.filter((s) => s !== primary) };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**نحوه تست:** `lib/specialtyDisplay.test.js` — آرایهٔ خالی؛ تکتخصص؛ ریشه وسط آرایه؛
|
||||||
|
هیچ ریشهای نبودن؛ آیتمِ بدون `name` که باید حذف شود.
|
||||||
|
|
||||||
|
### ۴. کارت پزشک — تخصص اصلی و `+N`
|
||||||
|
|
||||||
|
در `app/component/ItemDoctor.js` بلوک `<p>` تخصص با این جایگزین شود:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const { primary, rest } = splitSpecialties(doctor?.specialties);
|
||||||
|
```
|
||||||
|
|
||||||
|
نمایش: نام `primary`، و اگر `rest.length > 0` یک چیپ کوچک `+{rest.length}` کنارش.
|
||||||
|
کلیک روی چیپ، `Popover` باز کند با فهرست کامل (`primary` و `rest`).
|
||||||
|
|
||||||
|
الگوی `Popover` را از `app/component/PopoverDate.js` بردار، از صفر ننویس.
|
||||||
|
|
||||||
|
سه نکتهٔ اجباری:
|
||||||
|
|
||||||
|
- روی `onClick` چیپ حتماً `e.preventDefault()` و `e.stopPropagation()` بزن. کارت داخل
|
||||||
|
`Link` است و بدون این، کلیک صفحهٔ پزشک را باز میکند.
|
||||||
|
- چیپ باید `<button type="button">` باشد با `aria-label` روشن مثل
|
||||||
|
`نمایش ${rest.length} تخصص دیگر`، نه `<span>` با `onClick`.
|
||||||
|
- ارتفاع کارت نباید تغییر کند. نام `primary` در یک خط با `truncate` بماند.
|
||||||
|
|
||||||
|
استایل چیپ با توکنهای موجود همان فایل: `text-[12px]` و `rounded-[4px]` و
|
||||||
|
`bg-[#F8F8FF]` و `text-[#616161]` — همان چیزی که بلوک امتیاز و رضایت استفاده میکند.
|
||||||
|
رنگ یا کلاس تازه اضافه نکن.
|
||||||
|
|
||||||
|
**نحوه تست:** `npm run test` با یک تست کامپوننتی —
|
||||||
|
پزشک با ۶ تخصص: متن `جراحی عمومی` هست و `+5` هست؛ کلیک روی `+5` هر شش نام را نشان میدهد؛
|
||||||
|
پزشک با ۱ تخصص: هیچ `+` در DOM نیست؛ پزشک بدون تخصص: کرش نمیکند.
|
||||||
|
سپس دستی در موبایل روی `/doctors` — ارتفاع کارت جهانتاب با کارت کناری یکی باشد.
|
||||||
|
|
||||||
|
### ۵. پوستر — والد درشت، زیرتخصصها یک خط متنی
|
||||||
|
|
||||||
|
**هر دو فایل** `components/doctor/poster/index.js` و `components/doctor/poster/PosterLight.js`.
|
||||||
|
هر دو دقیقاً همان `slice(0, 4)` را دارند.
|
||||||
|
|
||||||
|
منطق پوستر عمداً با کارت فرق دارد: در چاپ نه کلیک هست نه Popover، پس `+N` بیمعناست.
|
||||||
|
|
||||||
|
در `lib/specialtyDisplay.js` یک تابع دوم بگذار:
|
||||||
|
|
||||||
|
```js
|
||||||
|
/**
|
||||||
|
* زیرتخصصها بهشکل یک خط متنی، بریده روی مرز کلمه با بودجهٔ کاراکتر.
|
||||||
|
* پوستر کادر ثابت دارد و overflow-hidden است؛ هر ردیف اضافه، بخشهای پایین را بیرون میاندازد.
|
||||||
|
*/
|
||||||
|
export function posterSpecialtyLine(rest, budget = 90) {
|
||||||
|
const names = rest.map((s) => s.name);
|
||||||
|
const shown = [];
|
||||||
|
let used = 0;
|
||||||
|
|
||||||
|
for (const name of names) {
|
||||||
|
const cost = name.length + (shown.length ? 3 : 0); // ' · '
|
||||||
|
if (used + cost > budget) break;
|
||||||
|
shown.push(name);
|
||||||
|
used += cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hidden = names.length - shown.length;
|
||||||
|
|
||||||
|
return { text: shown.join(" · "), hidden };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
در پوستر: `primary` همان چیپ درشت فعلی بماند (فقط یکی، نه چهارتا).
|
||||||
|
زیرش یک `<p>` با `text-[20px]` و رنگ کمرنگتر موجود (`#C7DBF2` در تیره، معادلش در روشن)
|
||||||
|
که `text` را نشان میدهد و اگر `hidden > 0` بود `و {hidden} تخصص دیگر` را به آن میچسباند.
|
||||||
|
|
||||||
|
روی ظرف زیرتخصصها `max-h` بگذار معادل دو خط، تا حتی اگر بودجه اشتباه تنظیم شد،
|
||||||
|
کادر سرریز نکند.
|
||||||
|
|
||||||
|
**نحوه تست:** تست واحد `posterSpecialtyLine` — بودجهٔ کوچک با نامهای بلند؛ آرایهٔ خالی
|
||||||
|
(`text` تهی و `hidden` صفر)؛ یک نام بلندتر از کل بودجه (نباید وسط کلمه بریده شود).
|
||||||
|
سپس دستی: پوستر دکتر جهانتاب را از صفحهٔ پزشک بساز و مطمئن شو در هر دو تم روشن و تیره
|
||||||
|
هیچ چیزی بیرون از کادر نیست.
|
||||||
|
|
||||||
|
### ۶. placeholder کادر جستجو
|
||||||
|
|
||||||
|
```js
|
||||||
|
// components/doctors/search/SearchField.js:58
|
||||||
|
placeholder="جستجوی نام پزشک ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
به «جستجوی نام پزشک یا تخصص ...» تغییر کند. بکاند بعد از پرامپت همتا نام تخصص را هم میگردد
|
||||||
|
و placeholder فعلی دروغ میشود.
|
||||||
|
|
||||||
|
**نحوه تست:** تایپ `جراح گوارش` در کادر و دیدن دکتر جهانتاب در نتایج. این تست فقط بعد از
|
||||||
|
اجرای پرامپت بکاند معنا دارد.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **ترتیب اجرا اجباری است.** وظیفهٔ ۳ و ۴ بدون `parent_id` در پاسخ API کار نمیکنند.
|
||||||
|
اگر بکاند هنوز اجرا نشده، `splitSpecialties` همیشه `items[0]` را برمیگرداند و
|
||||||
|
نتیجه ظاهراً درست ولی غیرقابلاتکا میشود.
|
||||||
|
- **وظیفهٔ ۱ پیشنیاز وظیفهٔ ۲ است.** بدون فایلِ بهروز، فرزندهای ۱۶۷ تا ۱۷۱ در
|
||||||
|
`filterList` نیستند و گزینهٔ «همه» روی گروهی مینشیند که فرزندانش را نمیبیند.
|
||||||
|
- **یک قاعده، یک جا.** انتخاب «تخصص اصلی» فقط در `lib/specialtyDisplay.js`. اگر در
|
||||||
|
`ItemDoctor` یا پوستر دوباره نوشته شود، فردا دو جا واگرا میشوند. این همان دلیل ساختن
|
||||||
|
فایل است، نه abstraction برای آینده.
|
||||||
|
- **کلیک داخل `Link`.** بدون `stopPropagation` روی چیپ `+N`، هر بار که کاربر تخصصها را
|
||||||
|
میبیند به صفحهٔ پزشک پرت میشود. این را حتماً تست کن.
|
||||||
|
- **پوستر دو فایل است.** `index.js` تیره و `PosterLight.js` روشن. اصلاح یکی و فراموشی
|
||||||
|
دیگری، باگ را نصفه رها میکند.
|
||||||
|
- **سئو.** `data/specialties.json` به `app/sitemap.js` هم خوراک میدهد. بعد از همگامسازی،
|
||||||
|
سایتمپ تخصصهای تازه را میگیرد — این مطلوب است، ولی مطمئن شو اسکریپت در شکست، فایل را
|
||||||
|
خالی نمیکند وگرنه سایتمپ کوچک میشود و صفحات از ایندکس میافتند.
|
||||||
|
- **استایل.** MUI v5 و Tailwind و RTL و فونت Vazir. کلاس یا رنگ تازه اضافه نکن؛ از همان
|
||||||
|
توکنهای موجود در همان فایل استفاده کن. راهحل با CSS موقت یا `!important` پذیرفته نیست.
|
||||||
|
- **صفحهٔ جزئیات پزشک عمداً خارج از محدوده است.** breadcrumb شکستهاش عارضهٔ فایل کهنه است و
|
||||||
|
با وظیفهٔ ۱ خودبهخود درست میشود. اگر بعد از وظیفهٔ ۱ باز هم شکسته بود، آیتم تازه به
|
||||||
|
todo اضافه کن و گزارش بده.
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# بازطراحی حرفهای پوستر اشتراکگذاری پزشک (دانلود)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
## نقش
|
||||||
|
|
||||||
|
بهعنوان متخصص UI/UX عمل کن. خروجی باید یک پوستر برند-محورِ تمیز، مدرن، خوانا و «قابلاشتراک در شبکههای اجتماعی» باشد — نه صرفاً یک باکس اطلاعات.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
در صفحهی عمومی پزشک (`/doctor/[slug]`) دکمهی «اشتراک گذاری» یک Modal باز میکند و گزینهی «دانلود» یک پوستر PDF از پزشک میسازد (via `html2canvas` + `jsPDF`). پوستر فعلی ساده و نامتوازن است: چیدمان دو ستونهی خام، تایپوگرافی بدون سلسلهمراتب، بدون slogan/امتیاز/شبکههای اجتماعی، لوگو خیلی کوچک، و ابعاد ثابت `600×700` که با محتوای متغیر (لیست خدمات/آدرس بلند) سرریز/نامرتب میشود.
|
||||||
|
|
||||||
|
مکانیزم آمادهسازی تصویر قبلاً درست شده: `List.js` قبل از `html2canvas` منتظر لود همهی `<img>`ها و آماده شدن QR میماند، و عکس پروفایل از `DoctorAvatar` مشترک با صفحه میآید. **این بازطراحی فقط لایهی بصری/چیدمان پوستر است؛ منطق دانلود و انتظار تصویر را نشکن.**
|
||||||
|
|
||||||
|
## هدف
|
||||||
|
|
||||||
|
پوستر را به بهترین شکل ممکن بازطراحی کن با این الزامات کاربر:
|
||||||
|
1. **لوگوی سایت حتماً حاضر و واضح باشد** (نه لوگوی ۳۰px گوشه) — در هدر بهصورت برند، و بهصورت واترمارک ظریف در فوتر.
|
||||||
|
2. کیفیت دانلود بالا و خروجی «شبکهاجتماعیپسند».
|
||||||
|
3. RTL، فونت Vazir، هماهنگ با هویت بصری سایت.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/doctor/poster/index.js` | چیدمان اصلی پوستر — بازطراحی کامل |
|
||||||
|
| `components/doctor/poster/ImageProfile.js` | عکس پزشک (DoctorAvatar، ring) |
|
||||||
|
| `components/doctor/poster/QrImg.js` | QR + دامنه؛ `onReady` را نگهدار |
|
||||||
|
| `components/doctor/poster/Services.js` | لیست خدمات (`data.expertise`) |
|
||||||
|
| `components/doctor/poster/Address.js` | آدرس (`data.address[0].address`) |
|
||||||
|
| `components/doctor/poster/Telefon.js` | تلفنها (`data.address[*].phone`) |
|
||||||
|
| `components/doctor/detailDoctor/Share.js` | کانتینر مخفی `hiddenRef` (ابعاد ثابت) + Modal |
|
||||||
|
| `components/doctor/detailDoctor/List.js` | `handleDownloadPDF` (html2canvas + jsPDF) — منطق را نگهدار، فقط ابعاد/نام فایل |
|
||||||
|
| `lib/getStateInfoClient.js` | `matchedCity` (شامل `logo_url`, `site_name`, `slogan`, `social_media`, `domain`, `contact_phone`) |
|
||||||
|
| `app/globals.css` | کلاس `.bg-poster` (خط ۸۳۸) و `.bg-stroke-circle` (خط ۸۴۶) |
|
||||||
|
| `data/city.json` | فیلدهای هر شهر: `logo_url`, `site_name`, `slogan`, `social_media`, `domain` |
|
||||||
|
| `public/assets/images/` | `logo.png`, `logo-2.png`, `poster.png`, `stroke-circle.png` |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
`components/doctor/poster/index.js` (چیدمان فعلی خام):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="bg-poster overflow-hidden !pt-[32px] p-[24px] relative flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-start gap-[5px]">
|
||||||
|
<Image src="/assets/images/logo.png" width={30} height={30} alt="logo" />
|
||||||
|
<p className="text-[#E7EEF6] text-[16px] font-bold !-translate-y-1">{siteName}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-[40px] ... text-[20px] font-bold">{data?.name}</p>
|
||||||
|
<p className="text-[24px] mt-[20px] ...">تخصص: {/* specialties */}</p>
|
||||||
|
<Services data={data} />
|
||||||
|
<Address data={data} />
|
||||||
|
<Telefon data={data} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-between items-end min-w-[200px]">
|
||||||
|
<ImageProfile data={data} />
|
||||||
|
<QrImg onReady={onQrReady} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
`components/doctor/detailDoctor/Share.js` (کانتینر مخفی — ابعاد ثابت فعلی):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="fixed min-w-[600px] min-h-[700px] w-[600px] h-[700px] top-0 left-0 opacity-0 pointer-events-none -z-10">
|
||||||
|
<div ref={hiddenRef}>
|
||||||
|
<Poster data={data} onQrReady={handleQrReady} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
`components/doctor/detailDoctor/List.js` (دانلود — این منطق را نگهدار):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
await waitForImages(element);
|
||||||
|
const canvas = await html2canvas(element, {
|
||||||
|
backgroundColor: "#ffffff", useCORS: true, scale: 2, imageTimeout: 15000,
|
||||||
|
});
|
||||||
|
const imgData = canvas.toDataURL("image/png");
|
||||||
|
const imgWidth = 210;
|
||||||
|
const pxPerMm = canvas.width / imgWidth;
|
||||||
|
const imgHeight = canvas.height / pxPerMm;
|
||||||
|
const pdf = new jsPDF({ orientation: "portrait", unit: "mm", format: [imgWidth, imgHeight] });
|
||||||
|
pdf.addImage(imgData, "PNG", 0, 0, imgWidth, imgHeight);
|
||||||
|
pdf.save("poster.pdf");
|
||||||
|
```
|
||||||
|
|
||||||
|
## دادههای در دسترس
|
||||||
|
|
||||||
|
- پزشک (`data`): `name`, `specialties[].name`, `expertise[].name` (خدمات), `address[].address`, `address[].phone`, `experience`, `point` (امتیاز), `satisfaction`, `medical_system_code`, `img[0].url`, `social_media`.
|
||||||
|
- شهر (`getStateInfoClient().matchedCity`): `logo_url`, `site_name`, `slogan`, `domain`, `contact_phone`, `social_media` (instagram/telegram/whatsapp).
|
||||||
|
- QR: از `getStateInfoClient().fullUrl` (لینک همین صفحه).
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. کانواس ثابت و باکیفیت
|
||||||
|
|
||||||
|
ابعاد پوستر را به یک کانواس ثابت با نسبت مناسب شبکههای اجتماعی ببر تا خروجی همیشه مرتب و پرکیفیت باشد. پیشنهاد: **نسبت ۴:۵ (مثلاً `1080×1350`)** یا استوری `1080×1920`. نسبت ۴:۵ برای پست اینستاگرام بهینه است.
|
||||||
|
|
||||||
|
- در `Share.js` ابعاد کانتینر مخفی را به همان کانواس تغییر بده (مثلاً `w-[1080px] h-[1350px]`). چون این عنصر مخفی و خارج از viewport رندر میشود، سایز بزرگ مشکلی ندارد.
|
||||||
|
- در `List.js` میتوانی `scale` را روی `2` نگه داری (خروجی ۲۱۶۰×۲۷۰۰). اگر سایز کانواس را بزرگ کردی و خروجی سنگین شد، `scale: 1.5` هم قابل قبول است. نام فایل را معنادار کن: `` `poster-${data?.name ?? 'doctor'}.pdf` `` (کاراکترهای غیرمجاز را با `-` جایگزین کن).
|
||||||
|
|
||||||
|
### ۲. سیستم بصری (بهعنوان UI/UX)
|
||||||
|
|
||||||
|
یک زبان بصری منسجم بساز؛ همهی مقادیر زیر پیشنهادیاند، خودت بهعنوان طراح تنظیم کن:
|
||||||
|
|
||||||
|
- **پسزمینه:** گرادیان برند (آبی تیره → آبی، همخانواده با رنگ فعلی `#3987D4`/`#E7EEF6`). میتوانی `.bg-poster` (تصویر `poster.png`) را نگه داری یا با گرادیان CSS جایگزین کنی. اگر گرادیان میسازی، از رنگهای rgb/hex استفاده کن (Tailwind v3، بدون oklch) تا `html2canvas` درست رندر کند.
|
||||||
|
- **کارتهای شیشهای/بخشبندی:** بخش خدمات/آدرس/تلفن را داخل کارتهای نیمهشفاف با گوشهی گرد و فاصلهگذاری یکنواخت بگذار تا سلسلهمراتب دیده شود.
|
||||||
|
- **تایپوگرافی:** سلسلهمراتب واضح — نام پزشک بزرگترین (bold)، تخصص متوسط، برچسب بخشها (خدمات/آدرس/تلفن) کوچکتر با آیکون. فونت فقط Vazir (از قبل در پروژه).
|
||||||
|
- **رنگ تأکید:** نارنجی موجود (`CircleOrangeSm`) برای بولتها/CTA حفظ شود.
|
||||||
|
- **آواتار:** عکس دایرهای با حلقهی ظریف (`.bg-stroke-circle` فعلی یا ring با border)، سایز بزرگتر و بهعنوان کانون بصری بالای پوستر.
|
||||||
|
|
||||||
|
### ۳. هدر برند + لوگو (الزام کاربر)
|
||||||
|
|
||||||
|
هدر شامل:
|
||||||
|
- **لوگوی سایت واضح**: از `matchedCity?.logo_url` استفاده کن؛ اگر `null` بود fallback به `/assets/images/logo.png` (یا `logo-2.png` هرکدام مناسبتر). اندازهی محسوس (مثلاً ارتفاع ۴۸–۶۴px)، نه ۳۰px.
|
||||||
|
- `site_name` شهر + `domain` کنارش.
|
||||||
|
- در صورت وجود `slogan`، یک خط زیر لوگو.
|
||||||
|
|
||||||
|
مثال (pseudocode):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const logoSrc = matchedCity?.logo_url || "/assets/images/logo.png";
|
||||||
|
// ...
|
||||||
|
<header className="flex items-center gap-3">
|
||||||
|
<img src={logoSrc} alt="logo" className="h-14 w-auto" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[24px] font-bold text-white">{matchedCity?.site_name}</span>
|
||||||
|
<span className="text-[14px] text-[#B9D2EC]">{matchedCity?.domain}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
```
|
||||||
|
|
||||||
|
> نکته html2canvas: اگر `logo_url` از دامنهی remote بیاید و آن سرور هدر CORS نفرستد، ممکن است در canvas آلوده/خالی شود. برای remote از `<img crossOrigin="anonymous">` استفاده کن و اگر لود نشد به لوگوی local برگرد (onError → fallback). لوگوی local همیشه امن است.
|
||||||
|
|
||||||
|
### ۴. بدنه — بخشهای اطلاعاتی با مدیریت سرریز
|
||||||
|
|
||||||
|
- **هویت پزشک:** نام + برچسب تخصصها بهصورت chipهای کوچک (بهجای متن پیوسته با `|`). ردیف badgeها: سابقه (`experience` سال)، امتیاز (`point` با آیکون ستاره)، رضایت (`satisfaction%`)، کد نظام (`medical_system_code`) — هرکدام موجود بود.
|
||||||
|
- **خدمات (`expertise`):** حداکثر N مورد (مثلاً ۶) نشان بده؛ اگر بیشتر بود «+X مورد دیگر». از overflow جلوگیری کن (کانواس ثابت است، محتوای متغیر نباید بشکند).
|
||||||
|
- **آدرس:** یک آدرس اصلی؛ متن بلند را با `line-clamp` (۲ خط) محدود کن.
|
||||||
|
- **تلفن:** شمارهی اصلی؛ اگر چند شماره بود حداکثر ۲ مورد. `dir="ltr"` برای شمارهها.
|
||||||
|
- همهی اینها را طوری بچین که در ارتفاع ثابت کانواس جا شود؛ فضای خالی را با فاصلهگذاری متوازن پر کن، نه با کشیدن یک بخش.
|
||||||
|
|
||||||
|
### ۵. فوتر — QR + CTA + واترمارک لوگو
|
||||||
|
|
||||||
|
- بلوک QR (همان `QrImg`) با یک برچسب CTA: «برای رزرو نوبت اسکن کنید» و دامنه زیر آن.
|
||||||
|
- آیکون/آیدی شبکههای اجتماعی از `matchedCity?.social_media` (اینستاگرام/تلگرام/واتساپ) اگر موجود بود.
|
||||||
|
- **واترمارک لوگو**: نسخهی کمرنگ لوگو در گوشه/پسزمینهی فوتر برای تأکید برند (الزام «لوگوی سایتم باید باشد»).
|
||||||
|
- `QrImg` باید `onReady` را همچنان صدا بزند (منطق انتظار دانلود در `List.js` به آن وابسته است) — این prop و رفتارش را نگهدار.
|
||||||
|
|
||||||
|
### ۶. حفظ سازگاری با پایپلاین دانلود
|
||||||
|
|
||||||
|
- `List.js`: `waitForImages`, `imageTimeout`, `useCORS`, و گارد `qrReady` را نگهدار؛ فقط ابعاد format و نام فایل را در صورت نیاز بهروزرسانی کن.
|
||||||
|
- `Share.js`: کانتینر باید همچنان **رندرشده ولی مخفی** بماند (`opacity-0 pointer-events-none -z-10`, نه `display:none`) تا تصاویر لود شوند و html2canvas بتواند snapshot بگیرد.
|
||||||
|
- `Poster` همچنان props `{ data, onQrReady }` را بگیرد.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- Tailwind v3 است (رنگها rgb/hex، بدون oklch) — از رنگهای مدرن غیرقابلرندر در html2canvas پرهیز کن.
|
||||||
|
- فونت فقط **Vazir**؛ فونت جدید اضافه نکن. مطمئن شو وزنهای استفادهشده در `globals.css` تعریف شدهاند (font-display swap) تا در snapshot درست بیایند.
|
||||||
|
- همهی assetها **local** ترجیح داده شوند؛ برای remote (مثل `logo_url`) از `crossOrigin="anonymous"` + fallback استفاده کن.
|
||||||
|
- تصاویر `next/image` در پوستر: ترجیحاً `priority` بده تا lazy-load نشوند (مثل `ImageProfile`). برای المانهای تزئینی میتوانی از `<img>` ساده استفاده کنی.
|
||||||
|
- خروجی نهایی را **واقعی تست کن**: باز کردن Modal → دانلود → باز کردن PDF و بررسی: لوگو واضح، عکس پزشک، QR، خدمات/آدرس/تلفن مرتب، بدون سرریز، و چیدمان متوازن برای پزشکی با دادهی کم و پزشکی با دادهی زیاد (edge case).
|
||||||
|
- RTL: چیدمان و alignment راستبهچپ درست باشد؛ شمارهها `ltr`.
|
||||||
|
- کیفیت: متن نباید تار شود — کانواس ثابت + `scale ≥ 1.5`.
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# وضعیت «نوبتدهی فعال/غیرفعال» پروفایل پزشک از booking_locations
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
پرامپت همتای backend که **باید اول اجرا شود**:
|
||||||
|
`clinicpro/.claude/prompt/fix-public-booking-state-and-admin-context-slots.md`
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
برای «دکتر تست» (`bcabb3a8-cae3-45ec-876c-548f9c1e1569`) صفحهٔ
|
||||||
|
`/doctor/bcabb3a8-…` پیام «نوبتدهی غیرفعال است» نشان میدهد در حالی که نوبتدهی
|
||||||
|
کلینیکش فعال است و همین صفحه، `booking_locations` معتبر (با `next_available_at` غیرتهی)
|
||||||
|
را server-side میگیرد.
|
||||||
|
|
||||||
|
علت (با curl تأیید شد): فیلدهای `active` / `free_turn` پاسخ `GET /api/v1/doctor/{uuid}`
|
||||||
|
در backend فقط از **برنامهٔ شخصی** پزشک ساخته میشدند؛ برنامهٔ شخصیِ دکتر تست غیرفعال
|
||||||
|
است و برنامهٔ کلینیکش دیده نمیشد. پرامپت همتا این را با تجمیع همهٔ برنامهها اصلاح میکند.
|
||||||
|
|
||||||
|
این پرامپت سمت سایت را defensive میکند: بنر و دکمهٔ نوبتدهی پروفایل نباید فقط به
|
||||||
|
`doctor.active` تکیه کند وقتی خودِ صفحه دادهٔ دقیقتر (`booking_locations`) را در دست دارد.
|
||||||
|
دو منبع نباید بتوانند حرف متناقض بزنند.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
قاعدهٔ واحد برای پروفایل پزشک:
|
||||||
|
|
||||||
|
- **نوبتدهی فعال** ⇔ `booking_locations` غیرخالی است (backend فقط محلهای واقعاً
|
||||||
|
قابلرزرو را برمیگرداند: آدرسدار + شیفت فعال روی همان آدرس).
|
||||||
|
- برچسب «اولین نوبت آزاد» از کمینهٔ `next_available_at` بین محلها؛ اگر همه `null` بودند،
|
||||||
|
محلها فعالند ولی فعلاً ظرفیت ندارند → «فعلاً نوبت خالی ندارد» (نه «غیرفعال»).
|
||||||
|
- `doctor.active === false` همراه با `booking_locations` خالی → «نوبتدهی غیرفعال است»
|
||||||
|
(رفتار فعلی، درست).
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `app/doctor/[slug]/page.js:43,128-150` | `getBookingLocations` server-side — از قبل موجود |
|
||||||
|
| `components/doctor/appointmentList/index.js:8,20-24` | نوار موبایل — `doctor?.active === false \|\| !doctor?.free_turn` |
|
||||||
|
| `components/doctor/appointmentList/ItemAppointment.js:10,42-56` | کارت دسکتاپ — `bookingDisabled = turn?.active === false` |
|
||||||
|
| `app/component/ItemDoctor.js:89` | کارت لیست پزشکان — فقط فیلد backend (بدون تغییر) |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
### کارت دسکتاپ — `components/doctor/appointmentList/ItemAppointment.js:10`
|
||||||
|
|
||||||
|
```js
|
||||||
|
function ItemAppointment({ turn, loading, doctorSlug }) {
|
||||||
|
const bookingDisabled = !loading && turn?.active === false;
|
||||||
|
...
|
||||||
|
{bookingDisabled || !turn?.free_turn
|
||||||
|
? "نوبتدهی غیرفعال است"
|
||||||
|
: `اولین نوبت آزاد: ${turn.free_turn}`}
|
||||||
|
```
|
||||||
|
|
||||||
|
### نوار موبایل — `components/doctor/appointmentList/index.js:20-24`
|
||||||
|
|
||||||
|
```js
|
||||||
|
{doctor?.active === false || !doctor?.free_turn
|
||||||
|
? "نوبتدهی غیرفعال است"
|
||||||
|
: `اولین نوبت آزاد: ${doctor.free_turn}`}
|
||||||
|
```
|
||||||
|
|
||||||
|
`AppointmentList` از `app/doctor/[slug]/page.js` رندر میشود که `bookingLocations` را
|
||||||
|
همانجا دارد (`:128-129`) ولی به این کامپوننت پاس نمیدهد.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. پاسدادن `bookingLocations` به AppointmentList
|
||||||
|
|
||||||
|
در `app/doctor/[slug]/page.js` همان آرایهٔ گرفتهشده را prop بده:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<AppointmentList doctor={doctor} doctorSlug={slug} bookingLocations={bookingLocations} />
|
||||||
|
```
|
||||||
|
|
||||||
|
(نام prop و محل رندر را با کد واقعی صفحه تطبیق بده — `AppointmentList` ممکن است از طریق
|
||||||
|
کامپوننت میانی رندر شود؛ در آن صورت prop را از همان مسیر عبور بده.)
|
||||||
|
|
||||||
|
### ۲. قاعدهٔ واحد فعال/غیرفعال
|
||||||
|
|
||||||
|
یک helper کوچک در همان `components/doctor/appointmentList/` (نه util سراسری جدید):
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function bookingState(doctor, bookingLocations) {
|
||||||
|
const hasLocations = Array.isArray(bookingLocations) && bookingLocations.length > 0;
|
||||||
|
if (!hasLocations && doctor?.active === false) return { enabled: false, label: "نوبتدهی غیرفعال است" };
|
||||||
|
if (!hasLocations) return { enabled: doctor?.active !== false, label: doctor?.free_turn ? `اولین نوبت آزاد: ${doctor.free_turn}` : "نوبتدهی غیرفعال است" };
|
||||||
|
const earliest = bookingLocations
|
||||||
|
.map((l) => l.next_available_at)
|
||||||
|
.filter((t) => t != null)
|
||||||
|
.sort((a, b) => a - b)[0] ?? null;
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
label: earliest != null
|
||||||
|
? `اولین نوبت آزاد: ${formatJalali(earliest)}`
|
||||||
|
: "فعلاً نوبت خالی ندارد",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `formatJalali` با `jalali-moment`/`moment-jalaali` موجود پروژه: روزِ هفته + ساعت
|
||||||
|
(مثل «شنبه ۰۹:۰۰») تا با فرمت `free_turn` backend همخانواده باشد. الگوی
|
||||||
|
`moment.unix(ts)` مثل `app/component/date/dateTime/index.js`.
|
||||||
|
- هر دو کامپوننت (`index.js` نوار موبایل و `ItemAppointment.js`) از همین helper استفاده
|
||||||
|
کنند؛ شرطهای تکراری فعلی حذف شوند.
|
||||||
|
- دکمهٔ «دریافت نوبت» با `enabled === true` فعال است حتی وقتی `earliest === null`
|
||||||
|
(صفحهٔ رزرو خودش روزهای بدون ظرفیت را نشان میدهد).
|
||||||
|
|
||||||
|
### ۳. کارت لیست پزشکان — بدون تغییر
|
||||||
|
|
||||||
|
`app/component/ItemDoctor.js:89` فقط `free_turn`/`active` پاسخ لیست را دارد و
|
||||||
|
`booking_locations` برای هر آیتم لیست fetch نمیشود (N درخواست اضافه ممنوع). بعد از فیکس
|
||||||
|
backend همین فیلدها درست میشوند — این فایل را دست نزن.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **backend اول.** قبل از اجرای پرامپت همتا، `doctor.active` برای دکتر تست همچنان false
|
||||||
|
برمیگردد؛ helper این را میپوشاند ولی تست کامل فقط بعد از هر دو ممکن است.
|
||||||
|
- پاسخهای API double-nested: `json?.data?.data ?? json?.data` — `getBookingLocations`
|
||||||
|
موجود در صفحه همین را رعایت میکند؛ عوضش نکن.
|
||||||
|
- `booking_locations` فقط محلهای معتبر را دارد (فیلتر backend از پرامپتهای قبلی) —
|
||||||
|
دوباره فیلتر نکن.
|
||||||
|
- رشتههای جدید فارسی، RTL، تم موجود (MUI v5 + Tailwind، فونت Vazir) — طراحی جدید نساز.
|
||||||
|
- slug پزشک = `uuid`؛ `params` همیشه `await`؛ صفحه `generateMetadata` دارد — دست نزن.
|
||||||
|
- تست دستی: `/doctor/bcabb3a8-cae3-45ec-876c-548f9c1e1569` باید «اولین نوبت آزاد» و دکمهٔ
|
||||||
|
فعال «دریافت نوبت» نشان دهد (دکتر تست فقط برنامهٔ کلینیکی فعال دارد). یک پزشک واقعاً
|
||||||
|
غیرفعال (بدون هیچ محل) همچنان «نوبتدهی غیرفعال است» ببیند.
|
||||||
|
- بعد از تغییرات: `npm run lint` و `npm run build` هر دو سبز.
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
# SEO پیشرفته صفحه پزشک: Schema کامل، Sitelinks، Knowledge Panel، Local SEO
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — این پرامپت برای فعالسازی Sitelinks و Knowledge Panel گوگل برای صفحهی هر پزشک است (هدف: نتیجهی جستجوی نام پزشک شبیه نتایج Zocdoc/Healthgrades با Knowledge Panel، عکس، شبکههای اجتماعی، آدرس، ساعات کاری، امتیاز).
|
||||||
|
|
||||||
|
**وابستگی cross-repo:** فیلد `socialMedia` پزشک هنوز در backend وجود ندارد — پرامپت همتا در `clinicpro/.claude/prompt/doctor-social-media-field.md` این فیلد را اضافه میکند. **آن پرامپت را اول اجرا کن**، سپس این یکی را، چون بخشهای `sameAs` JSON-LD و کارت شبکههای اجتماعی در UI به آن فیلد نیاز دارند. اگر هنوز اجرا نشده، آن بخشها را با حالت "اگر `doctor.socialMedia` وجود داشت" مشروط بنویس تا بدون خطا گریسفول fallback شود.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
بررسی کد واقعی نشان داد:
|
||||||
|
- `app/doctor/[slug]/page.js` فعلاً فقط schema `Physician` ساده (نام، تخصص، عکس، آدرس متنی) و یک `BreadcrumbList` دارد.
|
||||||
|
- دادهی واقعی API (`docs/api/doctor.md`) شامل: `point`/`satisfaction` (امتیاز و رضایت، میتواند `AggregateRating` شود)، چندین `address[]` با `clinics[]` (هرکدام `name`/`address`/`telephone`)، اما این آرایه مستقیماً در `getDoctor()` فعلی fetch نمیشود (endpoint جدا: `GET /api/v1/clinic-pro/doctor-addresses/{doctorId}` که `map.latitude`/`map.longitude` هم دارد).
|
||||||
|
- نظرات از `GET /api/v1/comments/{uuid}` میآید (`comments` در کامپوننت `Doctor()`) — میتواند منبع `Review` schema باشد.
|
||||||
|
- مقالات/پرسشوپاسخ پزشک در حال حاضر در هیچجای کد دیده نشد — این پروژه فعلاً **مقاله یا FAQ مرتبط به پزشک خاص ندارد** (فقط `app/blogs`/`app/blog/[slug]` عمومی است، بدون ارتباط به یک پزشک مشخص). بخشهای مرتبط با Article/FAQPage در این پرامپت را فقط برای ساختار سایت عمومی (نه per-doctor) طراحی کن، مگر اینکه در حین اجرا API برای FAQ پزشک پیدا شود.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
برای `app/doctor/[slug]/page.js` (و در حد لازم `app/doctors/page.js`, `app/clinics/page.js`, `app/sitemap.js`, `app/robots.js`, `app/layout.js`) موارد زیر اضافه/تکمیل شود:
|
||||||
|
1. Schema.org کاملتر برای پزشک (Physician + AggregateRating + Review + LocalBusiness برای هر مطب + ImageObject + sameAs)
|
||||||
|
2. Sitemap تفکیکشده (پزشکان، شهرها، تخصصها، مقالات) بهجای فایل واحد فعلی
|
||||||
|
3. ساختار URL/Internal Linking برای افزایش شانس Sitelinks
|
||||||
|
4. متادیتای دقیقتر هماهنگ با Knowledge Panel
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش فعلی | تغییر لازم |
|
||||||
|
|------|----------|------------|
|
||||||
|
| `app/doctor/[slug]/page.js` | `getDoctor()` با `cache()`، schema `Physician` ساده، breadcrumb | افزودن AggregateRating, Review, LocalBusiness(×N مطب), ImageObject, sameAs؛ fetch کردن آدرسهای مطب |
|
||||||
|
| `components/doctor/index.js` | رندر breadcrumb بصری (تخصص > نام) - قبلاً به Link تبدیل شده | بدون تغییر مگر اضافهکردن نمایش شبکههای اجتماعی اگر `doctor.socialMedia` وجود داشت |
|
||||||
|
| `app/sitemap.js` | یک فایل واحد، `getDoctorUrls`/`getClinicUrls`/`getBlogUrls` با `fetch` + `revalidate: 3600` | تفکیک به چند sitemap با `app/sitemap/[id]/route.js` یا multi-sitemap pattern رسمی Next.js 15 (`generateSitemaps`) |
|
||||||
|
| `app/robots.js` | `disallow: '/dashboard'` فقط؛ از `DEV_MODE` میخواند | اضافهکردن `/panel` به disallow؛ بدون تغییر در منطق DEV_MODE |
|
||||||
|
| `app/layout.js` | JSON-LD سراسری `Organization` + `WebSite` با `SearchAction` | بررسی کن `sameAs` سازمانی (لینک شبکههای اجتماعی نوبت724، نه پزشک) باید اینجا اضافه شود اگر `data/city.json` چنین دادهای دارد |
|
||||||
|
| `clinicpro/docs/api/doctor.md` | مرجع شکل دقیق پاسخ API | فقط مرجع خوانده شود، تغییر ندهید (این فایل پروژه backend است) |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
### `app/doctor/[slug]/page.js` — schema فعلی (ناقص)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const jsonLd = doctor
|
||||||
|
? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Physician",
|
||||||
|
name: `دکتر ${doctor.name}`,
|
||||||
|
...(specialtyNames && { medicalSpecialty: specialtyNames }),
|
||||||
|
...(doctor.img && { image: doctor.img }),
|
||||||
|
...(doctor.address && {
|
||||||
|
address: {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
streetAddress: doctor.address,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
```
|
||||||
|
|
||||||
|
مشکلات این بلوک:
|
||||||
|
- `image` باید `ImageObject` کامل (با `url`, `width`, `height`) باشد، نه رشتهی خام.
|
||||||
|
- `address` فقط یک متن ساده است؛ مطبهای واقعی پزشک (با مختصات جغرافیایی) اصلاً fetch نمیشوند در این صفحه.
|
||||||
|
- بدون `aggregateRating`، بدون `review`، بدون `sameAs`، بدون `telephone`.
|
||||||
|
- `doctor.point`/`doctor.satisfaction` (که در پاسخ API موجود است طبق `docs/api/doctor.md`) استفاده نمیشوند.
|
||||||
|
|
||||||
|
### `app/sitemap.js` — فعلاً یک فایل واحد
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default async function sitemap() {
|
||||||
|
try {
|
||||||
|
const domain = getCurrentDomain();
|
||||||
|
const baseUrl = getBaseUrl(domain);
|
||||||
|
const [staticPages, doctorUrls, clinicUrls, blogUrls] = await Promise.all([
|
||||||
|
Promise.resolve(getStaticPages(baseUrl)),
|
||||||
|
getDoctorUrls(baseUrl),
|
||||||
|
getClinicUrls(baseUrl),
|
||||||
|
getBlogUrls(baseUrl),
|
||||||
|
]);
|
||||||
|
const allUrls = [...staticPages, ...doctorUrls, ...clinicUrls, ...blogUrls];
|
||||||
|
return allUrls.filter(/* dedupe */);
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
این تابع همهچیز را در یک فایل XML واحد میریزد (`getDoctorUrls` با `limit=500`). اگر تعداد پزشکان از ۵۰هزار بیشتر شود (محدودیت گوگل برای یک sitemap)، باید چندتایی شود — Next.js 15 از `generateSitemaps()` پشتیبانی میکند.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. Schema.org کامل برای صفحه پزشک
|
||||||
|
|
||||||
|
در `app/doctor/[slug]/page.js`، fetch آدرسهای مطب را اضافه کن (مشابه الگوی موجود `getDoctor`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const getDoctorAddresses = cache(async (doctorId) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/v1/clinic-pro/doctor-addresses/${doctorId}`, {
|
||||||
|
next: { revalidate: 3600, tags: [`doctor-addresses-${doctorId}`] },
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const json = await res.json();
|
||||||
|
return json?.data ?? [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
سپس در کامپوننت `Doctor()`، schema را به این شکل بازنویسی کن:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const addresses = doctor ? await getDoctorAddresses(doctor.id) : [];
|
||||||
|
|
||||||
|
const jsonLd = doctor
|
||||||
|
? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Physician",
|
||||||
|
"@id": `https://www.nobat724.com/doctor/${doctor.uuid}#physician`,
|
||||||
|
name: `دکتر ${doctor.name}`,
|
||||||
|
url: `https://www.nobat724.com/doctor/${doctor.uuid}`,
|
||||||
|
...(specialtyNames && { medicalSpecialty: specialtyNames }),
|
||||||
|
...(doctor.img && {
|
||||||
|
image: {
|
||||||
|
"@type": "ImageObject",
|
||||||
|
url: imageUrl(doctor.img),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(doctor.point && Number(doctor.point) > 0 && {
|
||||||
|
aggregateRating: {
|
||||||
|
"@type": "AggregateRating",
|
||||||
|
ratingValue: doctor.point,
|
||||||
|
bestRating: "5",
|
||||||
|
ratingCount: comments?.length || 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(comments?.length > 0 && {
|
||||||
|
review: comments.slice(0, 5).map((c) => ({
|
||||||
|
"@type": "Review",
|
||||||
|
author: { "@type": "Person", name: c.author || "بیمار" },
|
||||||
|
reviewBody: c.text || c.body,
|
||||||
|
...(c.rate && {
|
||||||
|
reviewRating: { "@type": "Rating", ratingValue: c.rate, bestRating: "5" },
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
...(doctor.socialMedia && {
|
||||||
|
sameAs: Object.values(doctor.socialMedia).filter(Boolean),
|
||||||
|
}),
|
||||||
|
...(addresses.length > 0 && {
|
||||||
|
workLocation: addresses.map((addr) => ({
|
||||||
|
"@type": "MedicalClinic",
|
||||||
|
name: addr.clinic_name || addr.name || `مطب دکتر ${doctor.name}`,
|
||||||
|
address: {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
streetAddress: addr.address,
|
||||||
|
},
|
||||||
|
...(addr.telephone && { telephone: addr.telephone }),
|
||||||
|
...(addr.map?.latitude && addr.map?.longitude && {
|
||||||
|
geo: {
|
||||||
|
"@type": "GeoCoordinates",
|
||||||
|
latitude: addr.map.latitude,
|
||||||
|
longitude: addr.map.longitude,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
```
|
||||||
|
|
||||||
|
**نکته مهم:** بررسی کن فیلد دقیق rate در `comments[]` (نام فیلد امتیاز هر نظر) چیست — در `docs/api/doctor.md` این جزئیات مشخص نشده، باید با `console.log` یا بررسی `docs/api/rating.md` (اگر در backend وجود دارد) دقیق کنی، فرضیات بالا (`c.rate`, `c.text`, `c.author`) را verify کن قبل از commit.
|
||||||
|
|
||||||
|
### ۲. Sitemap تفکیکشده
|
||||||
|
|
||||||
|
`app/sitemap.js` فعلی را به ساختار چندتایی Next.js 15 تبدیل کن. ساختار پیشنهادی:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
sitemap.js # index — فقط static pages (home, about, contact, specialties)
|
||||||
|
doctor/sitemap.js # generateSitemaps() برای پزشکان، یکی به ازای هر 1000 پزشک
|
||||||
|
clinic/sitemap.js # همین الگو برای کلینیکها
|
||||||
|
blog/sitemap.js # همین الگو برای مقالات
|
||||||
|
```
|
||||||
|
|
||||||
|
نمونه برای `app/doctor/sitemap.js` (الگوی رسمی Next.js 15 برای sitemap چندتایی):
|
||||||
|
|
||||||
|
```js
|
||||||
|
export async function generateSitemaps() {
|
||||||
|
// اگر تعداد پزشکان مشخص نیست، یک تخمین اولیه برگردان (مثلاً بر اساس count از API)
|
||||||
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/doctors?page=1&limit=1`, {
|
||||||
|
next: { revalidate: 86400 },
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
const total = json?.meta?.totalRecords || 0;
|
||||||
|
const pageCount = Math.ceil(total / 1000);
|
||||||
|
return Array.from({ length: pageCount }, (_, i) => ({ id: i }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function sitemap({ id }) {
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
const res = await fetch(`${API_URL}/api/v1/doctors?page=${id + 1}&limit=1000`, {
|
||||||
|
next: { revalidate: 3600 },
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const json = await res.json();
|
||||||
|
const doctors = json?.data || [];
|
||||||
|
return doctors
|
||||||
|
.filter((d) => d?.uuid)
|
||||||
|
.map((d) => ({
|
||||||
|
url: `https://www.nobat724.com/doctor/${d.uuid}`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.8,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
همین الگو را برای `clinic` و `blog` تکرار کن. `app/sitemap.js` اصلی فقط صفحات ایستا (`/`, `/about-us`, `/contact-us`, `/specialties`, `/doctors`, `/clinics`, `/blogs`) را برمیگرداند — منطق `getStaticPages()` موجود قابل reuse است، فقط `getDoctorUrls`/`getClinicUrls`/`getBlogUrls` از این فایل حذف و به فایلهای جدید منتقل میشوند.
|
||||||
|
|
||||||
|
**نکته مهم:** صفحات تخصص (`?specialty=`) و شهر (subdomain) در حال حاضر URL مجزا با path ندارند (فیلتر با query param است) — اضافهکردن اینها به sitemap بهصورت URL جدا (`/doctors?specialty=X`) ارزش SEO کمی دارد چون گوگل query-param URLها را با اولویت پایینتر میبیند. اگر میخواهی این بخش (URL Structure برای تخصص/شهر بهصورت path-based مثل `/doctors/tehran/cardiology`) را هم پیادهسازی کنی، این یک تغییر بزرگتر در routing است که باید جدا و با تأیید قبلی انجام شود — در این پرامپت فقط طراحی پیشنهادی را در بخش «نکات مهم» مستند کن، کد routing را عوض نکن مگر کاربر صریحاً بخواهد.
|
||||||
|
|
||||||
|
### ۳. بهروزرسانی `robots.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: '*',
|
||||||
|
allow: '/',
|
||||||
|
disallow: ['/dashboard', '/panel'],
|
||||||
|
},
|
||||||
|
sitemap: `${baseUrl}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۴. کارت شبکههای اجتماعی در UI (مشروط به وجود فیلد backend)
|
||||||
|
|
||||||
|
در `components/doctor/detailDoctor/` (بررسی فایلهای موجود مثل `Share.js`) یک بخش کوچک برای نمایش آیکونهای شبکهاجتماعی پزشک اضافه کن، فقط اگر `doctor.socialMedia` مقدار غیر-null داشت:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{doctor?.socialMedia && Object.entries(doctor.socialMedia).some(([, v]) => v) && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{doctor.socialMedia.instagram && (
|
||||||
|
<a href={doctor.socialMedia.instagram} target="_blank" rel="noopener noreferrer">
|
||||||
|
<InstagramIcon />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{/* تکرار برای telegram, aparat, youtube, linkedin */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
آیکونهای لازم را در `components/icons/` بررسی کن — اگر آیکون اینستاگرام/تلگرام/آپارات/یوتیوب از قبل وجود ندارد، SVG ساده اضافه کن (الگوی فایلهای موجود در همان پوشه را دنبال کن).
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **هیچ تغییری در `services/api.js`/`services/response.js` (مسیر axios سمت کلاینت) ندهی** مگر برای `socialMedia` که نیاز به submit از فرم ادمین دارد (آن فرم در `clinicpro` است، نه اینجا).
|
||||||
|
- چون پروژه فعلاً مقاله/FAQ مرتبط به پزشک خاص ندارد، schemaهای `FAQPage` و `Article`/`BlogPosting` per-doctor را در این مرحله پیادهسازی نکن — فقط طراحی پیشنهادی (در صورت افزودن این قابلیت در آینده) را در پایان فایل توضیح بده، کد واقعی برایش ننویس.
|
||||||
|
- صفحه `app/clinic/[slug]/page.js` میتواند schema مشابه (`MedicalClinic` با `aggregateRating`/`geo`) بگیرد — اگر وقت بود همان الگوی schema پزشک را برای کلینیک هم تکرار کن، اما اولویت اول صفحه پزشک است.
|
||||||
|
- بعد از هر تغییر در `app/doctor/[slug]/page.js`، تست کن خروجی JSON-LD نهایی valid باشد — از Google Rich Results Test (بهصورت دستی، نه خودکار در این session) یا حداقل `JSON.parse(JSON.stringify(jsonLd))` بدون خطا.
|
||||||
|
- پس از تغییرات، حتماً `npm run build` را اجرا کن — مسیرهای جدید sitemap (`app/doctor/sitemap.js` و غیره) باید بدون خطا کامپایل شوند و در خروجی build بهعنوان route مجزا دیده شوند.
|
||||||
|
- اگر در حین اجرا متوجه شدی پرامپت backend (`doctor-social-media-field.md`) هنوز اجرا نشده، بخشهای `sameAs`/کارت شبکههای اجتماعی را با گارد `doctor?.socialMedia &&` (که در کد بالا هم رعایت شده) بدون خطا skip کن — منتظر آن پرامپت نمان.
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# بازنویسی صفحهی گرفتن نوبت (`/appointment/[doctorId]`) برای همخوانی با API واقعی
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — سایت عمومی نوبتدهی. این کار **صرفاً frontend** است؛ backend (`clinicpro`) کامل و درست است و **تغییر نمیکند**. مشکل این است که کل فلوی نوبتگیری روی یک قرارداد API **خیالی/قدیمی** نوشته شده که با endpointهای واقعی `clinicpro` همخوان نیست. همهی endpointهای لازم از قبل در `clinicpro/docs/api/appointment.md` و `clinicpro/docs/api/payment.md` مستند و آمادهاند.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
صفحهی `/appointment/[doctorId]` یک wizard چندمرحلهای است:
|
||||||
|
|
||||||
|
```
|
||||||
|
step 0: انتخاب تاریخ + ساعت (Date) → step 1/2: لاگین OTP → step 3: تکمیل اطلاعات (Detail) → step 4: پرداخت (Paying) → step 5/6: success/failed
|
||||||
|
```
|
||||||
|
|
||||||
|
این فلو در ظاهر کامل است ولی عملاً کار نمیکند چون **هر چهار فراخوانی اصلی API (اسلاتها، رزرو، پروفایل، پرداخت) با قرارداد واقعی backend مغایرت دارند** و علاوه بر آن صفحه از یک endpoint که اصلاً وجود ندارد (`appointment/not-available/{id}`) استفاده میکند.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
صفحهی نوبتگیری را به API واقعی وصل کن: استخراج درست پاسخها، پارامترها و بدنههای درست، و مپکردن ساختار واقعی اسلات (`sessions[].slots[]`) به UI موجود. هدف این است که یک کاربر واقعی بتواند تاریخ/ساعت را ببیند، اسلات انتخاب کند، نوبت رزرو کند و به درگاه پرداخت برود.
|
||||||
|
|
||||||
|
این کار باید **مرحلهبهمرحله** انجام شود (یک قابلیت در هر مرحله: پیادهسازی → build → تأیید → commit).
|
||||||
|
|
||||||
|
## قرارداد واقعی Backend (تأییدشده با curl روی `https://clinic-pro.ddev.site`)
|
||||||
|
|
||||||
|
### ۱. اسلاتها — `GET /api/v1/appointment-slots?doctor_uuid={uuid}&date={Y-m-d}` (PUBLIC)
|
||||||
|
|
||||||
|
- پارامتر `doctor_uuid` (UUID) است، **نه** `doctor_id`. فراخوانی با `doctor_id` خطای `ERR_VALIDATION_002` («دکتر یافت نشد») میدهد.
|
||||||
|
- پاسخ:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"doctor_uuid": "...",
|
||||||
|
"date": "2026-06-20",
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"start_time": "09:00",
|
||||||
|
"end_time": "13:00",
|
||||||
|
"slots": [
|
||||||
|
{ "start": 1781933400, "end": 1781934600, "start_time": "09:00", "end_time": "09:20", "location_id": 2581, "is_available": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "start_time": "15:00", "end_time": "17:00", "slots": [ ... ] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> هیچ کلید `morning`/`evening`/`hours_morning`/`hours_afternoon` وجود ندارد. اسلات کلید `time`/`status` ندارد؛ بهجایش `start_time` (رشتهی HH:MM)، `is_available` (boolean)، و `start`/`end` (Unix timestamp) دارد. شیفتها در آرایهی `sessions` هستند (نه دو دستهی ثابت صبح/عصر).
|
||||||
|
|
||||||
|
### ۲. رزرو نوبت — `POST /api/v1/appointment` (AUTH)
|
||||||
|
|
||||||
|
بدنه:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "doctor_uuid": "...", "slot_start": 1781933400, "slot_end": 1781934600, "note": "" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `doctor_uuid` (نه `doctor_id`)، `slot_start`/`slot_end` بهصورت Unix timestamp (همان `start`/`end` اسلات)، `note` اختیاری (نه `info`، نه شیء `slot`).
|
||||||
|
- پاسخ `201`: `{ success, data: { uuid, status: "pending", price, ... } }` — شناسهی نوبت `data.uuid` است (UUID)، **نه** `data.id`.
|
||||||
|
|
||||||
|
### ۳. پرداخت — `POST /api/v1/payment/appointment` (AUTH، باید مالک نوبت باشد)
|
||||||
|
|
||||||
|
بدنه:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "appointment_uuid": "...", "gateway": "mellat", "frontend_address": "https://.../payment/result" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- مسیر `payment/appointment` است، **نه** `payment`. `gateway` فقط `"mellat"` یا `"sep"`. فیلدهای `payment_method`/`bundle`/`entity_reference_id` وجود ندارند.
|
||||||
|
- پاسخ `200`: `{ success, data: { payment_uuid, redirect_url, order_id } }` — برای ریدایرکت از `data.redirect_url` استفاده کن (نه ساختن دستی URL).
|
||||||
|
|
||||||
|
### ۴. پروفایل کاربر — `GET /api/v1/user-profile/{uuid}` (AUTH)
|
||||||
|
|
||||||
|
پاسخ `{ success, data: {...} }`.
|
||||||
|
|
||||||
|
### ۵. interceptor مهم (`services/api.js`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
api.interceptors.response.use((response) => response.data, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
همهی `request.*` **یکبار** پاسخ را باز میکنند و **بدنهی HTTP** (`{ success, data }`) را برمیگردانند. پس دادهی واقعی همیشه در `result.data` است. (نکته: تابع server-side `fetchReq` در `lib/req.js` هم `response.data` را برمیگرداند؛ ولی صفحهی نوبت با `axiosInstance.get(...)` مستقیم کار میکند که باز نمیکند → آنجا `.data` لازم است.)
|
||||||
|
|
||||||
|
> هشدار double-nesting: پاسخ `GET /api/v1/doctor/{uuid}` سهلایه است (`{ success, data: { data: {...} } }`). در `axiosInstance` خام یعنی `res.data.data.data`. (این الگو قبلاً در `app/doctor/[slug]/page.js` با `res.data?.data?.data` رفع شده — همانجا را مرجع بگیر.)
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش | مشکل |
|
||||||
|
|------|-----|------|
|
||||||
|
| `app/appointment/[doctorId]/page.js` | server: doctor + disabledDates | `doctor = doctorRes.data` (باید `?.data?.data`)؛ فراخوانی endpoint ناموجود `appointment/not-available/{id}` |
|
||||||
|
| `services/response.js` | لایهی `request.*` | `getAppointment` با `doctor_id`؛ `postAppointment`/`postPayment`/`getPayment` با مسیر/بدنهی غلط؛ `getAppointmentNotAvailable` به route ناموجود |
|
||||||
|
| `app/component/date/dateTime/index.js` | fetch اسلاتها + آدرس | `getAppointment(doctor.id, date)`؛ خواندن `res.morning`/`res.evening`؛ `res.address` |
|
||||||
|
| `app/component/date/dateTime/hours/List.js` | grid اسلاتها | `appo[value ? "evening" : "morning"]`، `item.time`، `item.status` — هیچکدام در API نیست |
|
||||||
|
| `app/component/date/dateTime/SendAppo.js` | دکمهی «تایید نوبت» | فقط `setSelectedSlot(hour)` — وابسته به شکل `hour` |
|
||||||
|
| `components/appointment/Content.js` (در `components/appointment/index.js`) | fetch پروفایل کاربر | خواندن `res.uuid`/`res.name` (باید `res.data.*`) |
|
||||||
|
| `components/appointment/detail/SubmitData.js` | PATCH/POST پروفایل + POST نوبت | بدنهی نوبت غلط؛ خواندن `appointmentResponse.id` (باید `data.uuid`) |
|
||||||
|
| `components/appointment/paying/index.js` | POST پرداخت + ریدایرکت | بدنه/مسیر غلط؛ خواندن `response.uuid`؛ ساختن دستی URL درگاه |
|
||||||
|
| `clinicpro/docs/api/appointment.md` | قرارداد اسلات/رزرو | مرجع — تغییر نمیکند |
|
||||||
|
| `clinicpro/docs/api/payment.md` | قرارداد پرداخت | مرجع — تغییر نمیکند |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی مشکلدار)
|
||||||
|
|
||||||
|
### `app/appointment/[doctorId]/page.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const doctorRes = await axiosInstance.get(`${API_URL}/api/v1/doctor/${doctorId}`);
|
||||||
|
doctor = doctorRes.data; // ❌ باید doctorRes.data?.data?.data
|
||||||
|
|
||||||
|
if (doctor && doctor.id) { // ❌ doctor.id همیشه undefined
|
||||||
|
const disabledDatesRes = await axiosInstance.get(
|
||||||
|
`${API_URL}/api/v1/appointment/not-available/${doctor.id}` // ❌ route وجود ندارد (404)
|
||||||
|
);
|
||||||
|
disabledDates = disabledDatesRes.data?.data || [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `services/response.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
getAppointmentNotAvailable: (doctor_id) =>
|
||||||
|
api.get(`api/v1/appointment/not-available/${doctor_id}`, removeTokenHead), // ❌ route ناموجود
|
||||||
|
getAppointment: (doctor_id, date) =>
|
||||||
|
api.get(`api/v1/appointment-slots?date=${date}&doctor_id=${doctor_id}`, removeTokenHead), // ❌ doctor_id
|
||||||
|
postAppointment: (data) => api.post(`api/v1/appointment`, data, { requireAuth: true }),
|
||||||
|
postPayment: (data) => api.post(`api/v1/payment`, data, { requireAuth: true }), // ❌ مسیر payment/appointment
|
||||||
|
getPayment: (uuid) => api.get(`api/v1/payment/${uuid}`, { requireAuth: true }),
|
||||||
|
```
|
||||||
|
|
||||||
|
### `app/component/date/dateTime/index.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
request.getAppointment(doctor.id, date).then((res) => {
|
||||||
|
setAppo(res); // ❌ res، نه res.data
|
||||||
|
const hasAvailableMorning = res.morning?.some(s => s.status === "available"); // ❌ morning/status نیست
|
||||||
|
const hasAvailableEvening = res.evening?.some(s => s.status === "available");
|
||||||
|
...
|
||||||
|
});
|
||||||
|
// آدرس:
|
||||||
|
request.getDoctorAddress(hour.location_id).then((res) => setLocationAddress(res.address)); // ❌ res.data.address
|
||||||
|
```
|
||||||
|
|
||||||
|
### `app/component/date/dateTime/hours/List.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const list = appo && appo[value ? "evening" : "morning"]; // ❌ کلید وجود ندارد
|
||||||
|
// ...
|
||||||
|
disabled={item.status !== "available"} // ❌ is_available
|
||||||
|
{item.time} // ❌ start_time
|
||||||
|
```
|
||||||
|
|
||||||
|
### `components/appointment/detail/SubmitData.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const appointmentPayload = {
|
||||||
|
doctor_id: doctor.id, // ❌ doctor_uuid
|
||||||
|
slot: { // ❌ backend شیء slot نمیخواهد
|
||||||
|
time: selectedSlot.time, status: selectedSlot.status,
|
||||||
|
start_time_timestamp: selectedSlot.start_time_timestamp,
|
||||||
|
end_time_timestamp: selectedSlot.end_time_timestamp,
|
||||||
|
duration_per_patient: selectedSlot.duration_per_patient,
|
||||||
|
location_id: selectedSlot.location_id
|
||||||
|
},
|
||||||
|
info: "" // ❌ note
|
||||||
|
};
|
||||||
|
const appointmentResponse = await request.postAppointment(appointmentPayload);
|
||||||
|
if (appointmentResponse?.id) setAppointmentId(appointmentResponse.id); // ❌ data.uuid
|
||||||
|
```
|
||||||
|
|
||||||
|
### `components/appointment/paying/index.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const paymentPayload = {
|
||||||
|
payment_method: selectedBank, // ❌ gateway
|
||||||
|
bundle: "appointment", // ❌ حذف
|
||||||
|
entity_reference_id: appointmentId // ❌ appointment_uuid
|
||||||
|
};
|
||||||
|
const response = await request.postPayment(paymentPayload);
|
||||||
|
if (response?.uuid) { // ❌ data.redirect_url
|
||||||
|
const paymentUrl = `${process.env.NEXT_PUBLIC_API_URL}/payment/${response.uuid}`; // ❌ از redirect_url استفاده کن
|
||||||
|
window.location.href = paymentUrl;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `components/appointment/index.js` (خواندن پروفایل)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const res = await request.getUserProfile(parsedData.uuid);
|
||||||
|
if (res) {
|
||||||
|
const newData = { uuid: res.uuid, ... national_code: { value: res.national_code ... } }; // ❌ res.data.*
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
اجرای مرحلهبهمرحله؛ بعد از هر قابلیت `npm run build` و سپس commit جدا.
|
||||||
|
|
||||||
|
### ۱. اصلاح لایهی `request.*` در `services/response.js`
|
||||||
|
|
||||||
|
- `getAppointment` را با `doctor_uuid` بازنویسی کن (صفحه با `[doctorId]` کار میکند که همان uuid است):
|
||||||
|
|
||||||
|
```js
|
||||||
|
getAppointmentSlots: (doctor_uuid, date) =>
|
||||||
|
api.get(`api/v1/appointment-slots?doctor_uuid=${doctor_uuid}&date=${date}`, removeTokenHead),
|
||||||
|
```
|
||||||
|
|
||||||
|
- `postAppointment` بدنهاش از caller میآید (در وظیفه ۴ اصلاح میشود) — تغییری در امضای تابع لازم نیست.
|
||||||
|
- پرداخت را به مسیر و نام درست ببر:
|
||||||
|
|
||||||
|
```js
|
||||||
|
postAppointmentPayment: (data) =>
|
||||||
|
api.post(`api/v1/payment/appointment`, data, { requireAuth: true }),
|
||||||
|
```
|
||||||
|
|
||||||
|
- `getAppointmentNotAvailable` و تابع/route ناموجود `not-available` را حذف کن (در وظیفه ۲ مصرفش هم حذف میشود).
|
||||||
|
- اسمهای قدیمی (`getAppointment`, `postPayment`) را اگر جای دیگری مصرف نمیشوند حذف کن؛ اگر مصرف میشوند، همهی callerها را به نسخهی جدید مهاجرت بده. (با grep بررسی کن.)
|
||||||
|
|
||||||
|
### ۲. اصلاح `app/appointment/[doctorId]/page.js`
|
||||||
|
|
||||||
|
- استخراج درست doctor: `doctor = doctorRes.data?.data?.data;`
|
||||||
|
- منطق `disabledDates` و فراخوانی `appointment/not-available/{id}` را **حذف کن** (route وجود ندارد). prop `disabledDates` را یا حذف کن یا `[]` بفرست تا کامپوننتهای پاییندستی نشکنند. تاریخهای غیرفعال در همان پاسخ `appointment-slots` (نبودِ session/اسلات available) منعکس میشود؛ منطق غیرفعالسازی روز را به آن واگذار کن.
|
||||||
|
- `disabledDates` در `Time`/`SelectDatePicker` مصرف میشود — بررسی کن با آرایهی خالی رفتار درستی دارد (همهی روزها قابلانتخاب) و crash نمیکند.
|
||||||
|
|
||||||
|
### ۳. مپکردن ساختار واقعی اسلات به UI — `dateTime/index.js` + `hours/List.js`
|
||||||
|
|
||||||
|
- در `dateTime/index.js`: `request.getAppointmentSlots(doctor.uuid, date)` را صدا بزن (نه `doctor.id`؛ پس از وظیفه ۲، `doctor` شیء واقعی است و `uuid` دارد).
|
||||||
|
- پاسخ در `res.data` است (interceptor). یک adapter بنویس که `data.sessions[]` را به ساختاری که UI میخواهد تبدیل کند. **توهمسازی صبح/عصر نکن**؛ یا مستقیم روی `sessions` رندر کن، یا اگر میخواهی UI تبدار صبح/عصر را نگه داری، sessionها را بر اساس `start_time < "12:00"` به صبح/عصر تقسیم کن و این تصمیم را در adapter مستندِ کوچک بگذار.
|
||||||
|
- در `List.js`: بهجای `item.time` از `item.start_time`، بهجای `item.status !== "available"` از `!item.is_available`، و بهجای کلید `morning`/`evening` از خروجی adapter استفاده کن.
|
||||||
|
- `setSelectedSlot(hour)` در `SendAppo` باید شیئی نگه دارد که `start` و `end` (Unix) و `location_id` دارد — همان آبجکت اسلات از API. مطمئن شو این فیلدها تا `SubmitData` میرسند.
|
||||||
|
- بخش آدرس: `request.getDoctorAddress(hour.location_id)` پاسخش `res.data` است — `setLocationAddress(res.data?.address)`. (شکل واقعی `getDoctorAddress` را با یک فراخوانی تأیید کن.)
|
||||||
|
|
||||||
|
### ۴. اصلاح ساخت نوبت — `components/appointment/detail/SubmitData.js`
|
||||||
|
|
||||||
|
- بدنهی درست:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const appointmentPayload = {
|
||||||
|
doctor_uuid: doctor.uuid,
|
||||||
|
slot_start: selectedSlot.start,
|
||||||
|
slot_end: selectedSlot.end,
|
||||||
|
note: "",
|
||||||
|
};
|
||||||
|
const res = await request.postAppointment(appointmentPayload);
|
||||||
|
const appointmentUuid = res?.data?.uuid;
|
||||||
|
if (appointmentUuid) setAppointmentId(appointmentUuid); // نام state را appointmentUuid نگهدار یا همان appointmentId با مقدار uuid
|
||||||
|
```
|
||||||
|
|
||||||
|
- خطای `409` (اسلات قبلاً رزرو شده، `ERR_CONFLICT_001`) را به پیام فارسی مناسب map کن و کاربر را به مرحلهی انتخاب ساعت برگردان.
|
||||||
|
- بخش PATCH/POST پروفایل قبل از رزرو دستنخورده میماند مگر اینکه شکل پاسخ را بشکند — فقط مطمئن شو با interceptor (`res.data`) همخوان است.
|
||||||
|
|
||||||
|
### ۵. اصلاح پرداخت — `components/appointment/paying/index.js`
|
||||||
|
|
||||||
|
- بدنه و مسیر درست:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const paymentPayload = {
|
||||||
|
appointment_uuid: appointmentId, // همان uuid نوبت
|
||||||
|
gateway: selectedBank, // فقط "mellat" یا "sep"
|
||||||
|
frontend_address: `${window.location.origin}/payment/result`,
|
||||||
|
};
|
||||||
|
const res = await request.postAppointmentPayment(paymentPayload);
|
||||||
|
const redirectUrl = res?.data?.redirect_url;
|
||||||
|
if (redirectUrl) {
|
||||||
|
window.location.href = redirectUrl; // از redirect_url خود backend استفاده کن
|
||||||
|
} else {
|
||||||
|
setStep((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- لیست بانکها (`banks`) را با `gateway`های واقعی همخوان کن: `{ id: "mellat", title: "بانک ملت" }`، `{ id: "sep", title: "سامان (سپ)" }`. مقدار `id` باید دقیقاً `mellat`/`sep` باشد.
|
||||||
|
- مبلغ hard-code شدهی «10،000 تومان» را یا از `price` پاسخ نوبت (`data.price`، ریال) بگیر و با جداکنندهی فارسی به ریال/تومان نمایش بده، یا اگر در این مرحله در دسترس نیست، متن مبلغ ثابت را حذف کن (توهمسازی مبلغ ممنوع). `price` در پاسخ `POST /api/v1/appointment` هست — میتوان آن را تا این مرحله پاس داد.
|
||||||
|
|
||||||
|
### ۶. اصلاح خواندن پروفایل — `components/appointment/index.js`
|
||||||
|
|
||||||
|
- در هر دو `useEffect`، `const res = await request.getUserProfile(parsedData.uuid)` پاسخش `{ success, data }` است → از `res.data` بخوان: `res.data.uuid`, `res.data.national_code`, `res.data.name`, ... . منطق 404 (پروفایل ناموجود → فیلدها قابلویرایش) حفظ شود.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **هیچ تغییری در backend لازم نیست.** اگر به endpoint غایبی برخوردی، **متوقف شو و بپرس** (cross-repo) — دادهی جعلی یا route حدسی جایگزین نکن. مشخصاً `appointment/not-available` وجود ندارد و نباید بازسازی شود.
|
||||||
|
- **interceptor (`services/api.js`):** همهی `request.*` بدنهی `{ success, data }` را برمیگردانند → داده در `result.data`. صفحهی server-side با `axiosInstance` خام، doctor را سهلایه میگیرد (`res.data.data.data`).
|
||||||
|
- **slug = uuid:** پارامتر route `[doctorId]` در عمل uuid پزشک است؛ همان را به `doctor_uuid` بده، نه `doctor.id` عددی.
|
||||||
|
- **timestampها Unix هستند:** `slot.start`/`slot.end` مستقیم بهعنوان `slot_start`/`slot_end` میروند؛ تبدیل اضافه نکن.
|
||||||
|
- **adapter جدا:** برای اسلاتها یک تابع map کوچک بنویس (`sessions[]` → ساختار UI) تا کامپوننت نمایشی کمتغییر بماند و دیف کوچک شود.
|
||||||
|
- **Auth:** رزرو و پرداخت `requireAuth` میخواهند؛ در 401، `api.js` خودکار logout/redirect میکند — این رفتار را حفظ کن. فلوی OTP (step 1/2) دستنخورده میماند.
|
||||||
|
- **Multi-domain/RTL/Jalali:** تاریخها شمسی، مبالغ ریال با جداکنندهی فارسی، `matchedCity` موجود حفظ شود.
|
||||||
|
- **تست:** بعد از هر قابلیت `npm run build` (ESLint در این پروژه setup نشده؛ build خود type-check را انجام میدهد). در صورت امکان فلو را با یک پزشک واقعی (`4a0594b1-008b-478a-a593-259b95d8c2dd`) و تاریخ آینده دستی تست کن: اسلاتها باید لود شوند. سپس commit با پیام توصیفی برای همان قابلیت.
|
||||||
|
- **عدم رگرسیون:** `getAppointment`/`postPayment` قدیمی ممکن است جای دیگری مصرف شوند؛ قبل از حذف/تغییر امضا، با `grep -rn "getAppointment\|postPayment\|getAppointmentNotAvailable" app components services` همهی callerها را پیدا و مهاجرت بده.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# رفع جریان پرداخت و اطلاعات حساب در نوبتگیری آنلاین
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی).
|
||||||
|
|
||||||
|
> این مشکل **کاملاً frontend** است. بکاند `clinicpro` همهی endpointهای لازم را دارد و درست کار میکند: `POST /api/v1/appointment` (با `expires_at`)، `GET /api/v1/payment/config` (`test_mode`)، `POST /api/v1/payment/appointment` (`redirect_url` — در حالت تست از `MockGateway`)، `POST|GET /api/v1/payment/callback/{gateway}` (که نوبت را confirm و به `frontend_address` ریدایرکت میکند)، و `GET /api/v1/payment/{uuid}` (وضعیت پرداخت). نیازی به تغییر بکاند نیست. مرجع قرارداد: `clinicpro/docs/api/payment.md` و `docs/api/appointment.md`.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
در فرایند نوبتگیری آنلاین (`/appointment/[doctorId]`) سه مشکل گزارش شده: (۱) اطلاعات حساب کاربری بهدرستی در فیلدها ست نمیشود، (۲) شمارندهی زمان پرداخت کار نمیکند، (۳) درگاه پرداخت تست کار نمیکند. ریشهی هر سه، **اشتباه در خواندن شکل پاسخ بکاند** در سمت frontend است — مخصوصاً پاسخهای double-nested بکاند.
|
||||||
|
|
||||||
|
یادآوری معماری پاسخ بکاند: `BaseController::success(['data' => X])` تولید میکند `{ success, data: { data: X } }`. interceptor در `services/api.js` یک بار `response.data` را برمیگرداند، پس مقدار واقعی در **`res.data.data`** است (نه `res.data`).
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
۱. **شمارنده + appointmentId**: پس از `POST /appointment`، فرانت `res.data.uuid` و `res.data.expires_at` را میخواند، اما چون پاسخ double-nested است اینها `undefined` میشوند → `appointmentId` خالی (پرداخت کار نمیکند) و `expiresAt` خالی (شمارنده روی مقدار ثابت `PAYMENT_TTL` میماند و واقعی نیست).
|
||||||
|
۲. **درگاه تست**: درگاه تست خودِ بکاند است (`MockGateway`؛ وقتی `payment_test_mode=1`). فرانت باید: `redirect_url` بازگشتی از `POST /payment/appointment` را باز کند (که به callback بکاند میرود، نوبت را confirm میکند و به `frontend_address` با `?payment_uuid=...&status=...` برمیگردد). مشکل: `frontend_address` فعلی به `/payment/result` اشاره دارد که **route وجود ندارد**؛ صفحهی واقعی `/payment/[uuid]` است و callback `payment_uuid` را برمیگرداند.
|
||||||
|
۳. **صفحهی نتیجهی پرداخت** (`/payment/[uuid]`): پاسخ `GET /payment/{uuid}` را بهاشتباه میخواند (`setPayment(response)` بهجای `response.data.data`) و statusها اشتباهاند (`completed`/`canceled` بهجای `success`/`failed`).
|
||||||
|
۴. **اطلاعات حساب کاربری**: در `buildProfileData` کلیدها اصلاح شد (`label`→نام، `basic_insurance_id`، `gender` رشته، `national_code_approved`)؛ این وظیفه تثبیت/تأیید آن است.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/appointment/detail/SubmitData.js` | پس از `postAppointment`، `appointmentId`/`expiresAt` را ست میکند — **double-nest باگ** |
|
||||||
|
| `components/appointment/paying/index.js` | شمارنده (`expiresAt` prop)، `getPaymentConfig`، `postAppointmentPayment` (`redirect_url`)، `frontend_address` |
|
||||||
|
| `app/payment/[uuid]/page.js` | صفحهی نتیجهی پرداخت — مصرف `getPayment` با شکل اشتباه + statusهای اشتباه |
|
||||||
|
| `services/response.js` | `getPayment`، `postAppointmentPayment`، `getPaymentConfig` (urlها درستاند) |
|
||||||
|
| `components/appointment/index.js` | `buildProfileData` — اطلاعات حساب (وظیفه ۴، عمدتاً انجامشده) |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
### باگ ۱ — SubmitData: خواندن نتیجهی نوبت
|
||||||
|
```js
|
||||||
|
const res = await request.postAppointment(appointmentPayload);
|
||||||
|
const appointmentUuid = res?.data?.uuid; // ❌ undefined — باید res.data.data.uuid
|
||||||
|
if (appointmentUuid) {
|
||||||
|
setAppointmentId(appointmentUuid);
|
||||||
|
setAppointmentExpiresAt?.(res?.data?.expires_at ?? null); // ❌ undefined
|
||||||
|
}
|
||||||
|
```
|
||||||
|
بکاند `POST /appointment` پاسخ میدهد: `{ success, data: { data: { uuid, slot_start, slot_end, expires_at, status, ... } } }`.
|
||||||
|
|
||||||
|
### باگ ۲ — paying: frontend_address و config
|
||||||
|
```js
|
||||||
|
const res = await request.postAppointmentPayment({
|
||||||
|
appointment_uuid: appointmentId,
|
||||||
|
gateway: testMode ? "mellat" : selectedBank,
|
||||||
|
frontend_address: `${window.location.origin}/payment/result`, // ❌ route وجود ندارد
|
||||||
|
});
|
||||||
|
const redirectUrl = res?.data?.redirect_url; // ❌ باید res.data.data.redirect_url
|
||||||
|
```
|
||||||
|
- `getPaymentConfig` نیز: `res?.data?.test_mode` خوانده میشود ولی پاسخ `{ success, data: { test_mode } }` است (config با `success([...])` بدون nest اضافه ساخته شده؛ یعنی `res.data.test_mode` درست است — **این یکی را تأیید کن**، چون `config()` از `$this->success([...])` مستقیم استفاده میکند نه `['data'=>...]`).
|
||||||
|
- callback بکاند به `frontend_address` با `?payment_uuid=<uuid>&status=<status>` برمیگردد.
|
||||||
|
|
||||||
|
### باگ ۳ — payment/[uuid]/page.js
|
||||||
|
```js
|
||||||
|
const response = await request.getPayment(params.uuid);
|
||||||
|
setPayment(response); // ❌ باید response.data.data
|
||||||
|
// ...
|
||||||
|
const statusMap = { pending:..., completed:..., failed:..., canceled:... }; // ❌ بکاند: pending|success|failed|refunded
|
||||||
|
```
|
||||||
|
بکاند `GET /payment/{uuid}` → `{ success, data: { data: { uuid, order_id, amount_rials, status, gateway, type, reference_id, appointment_uuid, created_at } } }`. statusهای واقعی: `pending`, `success`, `failed`, `refunded`. مبلغ در `amount_rials` (ریال) است.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. رفع double-nest در SubmitData (شمارنده + appointmentId)
|
||||||
|
در `components/appointment/detail/SubmitData.js`:
|
||||||
|
```js
|
||||||
|
const res = await request.postAppointment(appointmentPayload);
|
||||||
|
const appt = res?.data?.data; // unwrap درست
|
||||||
|
const appointmentUuid = appt?.uuid;
|
||||||
|
if (appointmentUuid) {
|
||||||
|
setAppointmentId(appointmentUuid);
|
||||||
|
setAppointmentExpiresAt?.(appt?.expires_at ?? null);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> نکته: `expires_at` بکاند Unix ثانیه است؛ `paying/index.js` همان را در `computeLeft` با `expiresAt * 1000` به میلیثانیه تبدیل میکند — درست است؛ فقط باید مقدار واقعی برسد.
|
||||||
|
|
||||||
|
### ۲. اصلاح frontend_address و redirect_url در paying
|
||||||
|
در `components/appointment/paying/index.js`، تابع `handlePayment`:
|
||||||
|
- `frontend_address` را به مسیر معتبری بده که نتیجه را نشان میدهد. چون callback `?payment_uuid=...&status=...` را به همان آدرس append میکند و صفحهی نتیجه `/payment/[uuid]` است، یک **route نتیجه** لازم است که `payment_uuid` را از query بخواند و کاربر را به نتیجه ببرد. دو گزینه:
|
||||||
|
- **الف (ساده):** `frontend_address` را روی `${window.location.origin}/payment/result` بگذار و یک صفحهی سبک `app/payment/result/page.js` بساز که `payment_uuid` و `status` را از `searchParams` میخواند و به `/payment/${payment_uuid}` ریدایرکت (یا مستقیم وضعیت را نشان) میدهد. (پیشنهادی)
|
||||||
|
- **ب:** اگر صفحهی `[uuid]` را نتیجهی نهایی میگیری، میتوان مستقیم آن را هدف frontend_address نکرد چون uuid را callback append نمیکند (فقط `payment_uuid`)؛ پس گزینه الف تمیزتر است.
|
||||||
|
- `redirect_url` را از `res?.data?.data?.redirect_url` بخوان (double-nest):
|
||||||
|
```js
|
||||||
|
const res = await request.postAppointmentPayment({
|
||||||
|
appointment_uuid: appointmentId,
|
||||||
|
gateway: testMode ? "mellat" : selectedBank,
|
||||||
|
frontend_address: `${window.location.origin}/payment/result`,
|
||||||
|
});
|
||||||
|
const redirectUrl = res?.data?.data?.redirect_url;
|
||||||
|
if (redirectUrl) window.location.href = redirectUrl;
|
||||||
|
```
|
||||||
|
> در حالت تست، بکاند `MockGateway` یک `redirect_url` میدهد که مستقیم به callback بکاند میرود (`/api/v1/payment/callback/mock?...&mock=1&ResCode=0`) و خودِ بکاند نوبت را confirm و به `frontend_address` برمیگرداند. پس «درگاه تست» کاملاً سمت بکاند است و response واقعی میدهد — فرانت فقط `redirect_url` را باز میکند. **هیچ شبیهسازی پرداخت سمت فرانت اضافه نکن.**
|
||||||
|
|
||||||
|
### ۳. تأیید getPaymentConfig
|
||||||
|
`config()` بکاند از `$this->success(['test_mode' => ...])` استفاده میکند → پاسخ `{ success, data: { test_mode } }`. پس `res?.data?.test_mode` در `paying/index.js` **درست** است. فقط تأیید کن این مقدار را درست میخواند و دکمه بر اساسش «پرداخت آزمایشی»/درگاه را نشان میدهد.
|
||||||
|
|
||||||
|
### ۴. اصلاح صفحهی نتیجه `app/payment/[uuid]/page.js`
|
||||||
|
```js
|
||||||
|
const response = await request.getPayment(params.uuid);
|
||||||
|
setPayment(response?.data?.data ?? null); // unwrap درست
|
||||||
|
```
|
||||||
|
- statusها را با مقادیر واقعی بکاند هماهنگ کن: `pending` (در انتظار)، `success` (پرداخت موفق)، `failed` (ناموفق)، `refunded` (مسترد). برچسب/رنگ هر کدام.
|
||||||
|
- مبلغ از `payment.amount_rials` است (ریال) — برای نمایش تومان `/10`.
|
||||||
|
- روش پرداخت از `payment.gateway` (`mellat`/`sep`/`mock`).
|
||||||
|
- تاریخ از `payment.created_at` (Unix ثانیه).
|
||||||
|
|
||||||
|
### ۵. صفحهی واسط نتیجه (اگر گزینه الف انتخاب شد)
|
||||||
|
`app/payment/result/page.js` بساز:
|
||||||
|
- `payment_uuid` و `status` را از `useSearchParams` بخوان.
|
||||||
|
- اگر `payment_uuid` بود → `router.replace('/payment/' + payment_uuid)`؛ در غیر این صورت پیام خطا.
|
||||||
|
- این صفحه فقط یک واسط ریدایرکت سبک است (با لودینگ).
|
||||||
|
|
||||||
|
### ۶. تثبیت اطلاعات حساب کاربری (انجامشده — تأیید)
|
||||||
|
در `components/appointment/index.js` تابع `buildProfileData` قبلاً اصلاح شده: `name` از `profile.label`، `basic_insurance`/`supplementary_insurance` از `*_id` بهصورت `{id}`، `gender` رشتهی `profile.gender`، و `national_code.isEdit = !profile.national_code_approved`. تأیید کن این فیلدها در فرم (`detail/Form.js`) درست نمایش داده میشوند و در `SubmitData` درست ارسال میشوند (که سازگار است). اگر چیزی جا مانده اصلاح کن.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **بکاند تغییر نمیکند.** همهی endpointها موجود و درستاند؛ فقط مصرف frontend اصلاح میشود.
|
||||||
|
- پاسخهای `appointment` و `payment/{uuid}` و `payment/appointment` همگی **double-nested** اند (`res.data.data`)؛ ولی `payment/config` نیست (`res.data.test_mode`). به این تفاوت دقت کن.
|
||||||
|
- شمارنده باید از `expires_at` واقعیِ بکاند (Unix ثانیه) تغذیه شود؛ منطق فعلی `computeLeft` درست است، فقط ورودیاش باید برسد.
|
||||||
|
- پرداخت تست = درگاه تست بکاند (`MockGateway`)؛ فرانت فقط `redirect_url` را باز میکند و نتیجه را از callback/صفحهی نتیجه میگیرد. شبیهسازی فرانت ممنوع.
|
||||||
|
- App Router؛ صفحهی نتیجه `"use client"` (به `useSearchParams`/`useParams` نیاز دارد). RTL، فونت Vazir، MUI v5.
|
||||||
|
- بعد از تغییر: `npm run build` بدون خطا؛ جریان را end-to-end تست کن (نوبت → شمارنده فعال → پرداخت آزمایشی → callback → صفحهی نتیجه با وضعیت `success`).
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# رفع باگهای داشبورد کاربر: کوکی uuid اشتباه، URL غلط نوبتها/پرداختها، و crash هدر
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — سایت عمومی. **بعد از پرامپت backend اجرا شود.**
|
||||||
|
|
||||||
|
> **Cross-repo:** تب پرداختها به endpoint جدید backend وابسته است:
|
||||||
|
> `GET /api/v1/my/payments` (در `clinicpro/.claude/prompt/my-payments-list-endpoint.md`).
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
داشبورد کاربر چند خطای ۴۰۴ و یک crash میدهد:
|
||||||
|
|
||||||
|
1. **پروفایل ۴۰۴ (server-side):** `app/dashboard/page.js` پروفایل را با `cookieStore.get("uuid")` میگیرد، اما کوکی `uuid` هنگام لاگین با **uuid اوتیپی (send-code)** ست شده، نه uuid کاربر. پس `GET /user-profile/{otp-uuid}` ۴۰۴ میدهد (نه با پروفایل match میشود نه با کاربر).
|
||||||
|
2. **crash هدر:** `components/dashboard/userAccount/Head.js` مقدار `userInfo.realName` را روی نتیجهی `getParsedUserInfo()` میخواند که ممکن است `null` باشد → «Cannot read properties of null (reading 'realName')».
|
||||||
|
3. **نوبتهای من ۴۰۴:** `getMyAppointments(userId)` به `appointment/my-appointments/{userId}` میزند که **route ناموجود** است. route درست: `GET /api/v1/my/appointments` (کاربر از توکن).
|
||||||
|
4. **تراکنشها ۴۰۴:** `getMyPayments(userId)` به `payment/my-payments/{userId}` میزند که **وجود ندارد** → با endpoint جدید `GET /api/v1/my/payments` جایگزین میشود.
|
||||||
|
|
||||||
|
## ریشهی اصلی کوکی `uuid`
|
||||||
|
|
||||||
|
در `components/register/verificationPage/SendReq.js`، مقدار `uuid` که ست میشود **uuid اوتیپی** (از `send-code`) است، نه uuid کاربر. uuid واقعی کاربر در کوکی `userInfo` (`res.data.uuid` از userinfo) موجود است.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// SendReq.js — handleSetCookie
|
||||||
|
Cookies.set("uuid", uuid, { ... }); // ❌ uuid اوتیپی، نه uuid کاربر
|
||||||
|
```
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/register/verificationPage/SendReq.js` | ستکردن کوکی `uuid` هنگام لاگین — باید uuid کاربر شود |
|
||||||
|
| `app/dashboard/page.js` | fetch پروفایل server-side با کوکی `uuid` |
|
||||||
|
| `components/dashboard/userAccount/Head.js` | `userInfo.realName` بدون گارد null |
|
||||||
|
| `services/response.js` | `getMyAppointments`, `getMyPayments` — URLهای غلط |
|
||||||
|
| `components/dashboard/userAccount/sidebars/turns/index.js` | مصرف `getMyAppointments` |
|
||||||
|
| `components/dashboard/userAccount/sidebars/transactions/index.js` | مصرف `getMyPayments` |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
### `services/response.js` — URLهای ناموجود
|
||||||
|
|
||||||
|
```js
|
||||||
|
getMyAppointments: (userId, params) => api.get(`api/v1/appointment/my-appointments/${userId}`, { params, requireAuth: true }),
|
||||||
|
getMyPayments: (userId, params) => api.get(`api/v1/payment/my-payments/${userId}`, { params, requireAuth: true }),
|
||||||
|
```
|
||||||
|
|
||||||
|
### `app/dashboard/page.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const uuid = cookieStore.get("uuid")?.value; // ❌ uuid اوتیپی
|
||||||
|
profile = await fetchReq(`${API_URL}/api/v1/user-profile/${uuid}`, { headers: { Authorization: `Bearer ${token.value}` } });
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Head.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const userInfo = getParsedUserInfo();
|
||||||
|
// ...
|
||||||
|
{userInfo.realName} // ❌ اگر null → crash
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. کوکی `uuid` = uuid کاربر (`SendReq.js`)
|
||||||
|
|
||||||
|
- در `handleSetCookie`، بهجای ستکردن `uuid` اوتیپی، uuid واقعی کاربر را ست کن. چون `getInfo` بعداً پروفایل/userinfo را میگیرد، بهترین جا برای ستکردن `uuid` کاربر **داخل `getInfo`** است بعد از دریافت `res.data.uuid`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const getInfo = (token, cookieOptions) => {
|
||||||
|
request.getUserInfo({ headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
.then((res) => {
|
||||||
|
const profile = res?.data;
|
||||||
|
if (profile?.uuid) {
|
||||||
|
Cookies.set("uuid", profile.uuid, cookieOptions); // ✅ uuid کاربر (overwrite uuid اوتیپی)
|
||||||
|
Cookies.set("userInfo", JSON.stringify({ ...profile, username: profile.mobile_number }), cookieOptions);
|
||||||
|
// ... redirect / setStep ...
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
> کوکی `uuid` در `handleSetCookie` با uuid اوتیپی ست میشود؛ این خط را یا حذف کن یا بگذار و در `getInfo` با uuid کاربر overwrite کن (overwrite سادهتر است). نتیجه: کوکی نهایی `uuid` = uuid کاربر.
|
||||||
|
|
||||||
|
### ۲. گارد null در `Head.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const userInfo = getParsedUserInfo() || {};
|
||||||
|
// ...
|
||||||
|
{userInfo?.realName || "کاربر"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- اگر `user` (prop از `buildPatientUser`) هم اطلاعات دارد، میتوانی بهجای کوکی از `user.name` استفاده کنی؛ ولی حداقل گارد null اجباری است تا crash نشود.
|
||||||
|
|
||||||
|
### ۳. اصلاح URLها در `services/response.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
getMyAppointments: (params) => api.get(`api/v1/my/appointments`, { params, requireAuth: true }),
|
||||||
|
getMyPayments: (params) => api.get(`api/v1/my/payments`, { params, requireAuth: true }),
|
||||||
|
```
|
||||||
|
|
||||||
|
- امضای تابع `userId` را حذف کن (کاربر از توکن میآید). همهی callerها را بهروز کن.
|
||||||
|
|
||||||
|
### ۴. بهروزرسانی callerها (turns + transactions)
|
||||||
|
|
||||||
|
- در `sidebars/turns/index.js` و `sidebars/transactions/index.js`: `userId` را حذف کن، فقط `params` بفرست:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const response = await request.getMyAppointments(params); // turns
|
||||||
|
const response = await request.getMyPayments(params); // transactions
|
||||||
|
```
|
||||||
|
|
||||||
|
- شکل پاسخ paginated است (`paginated()` در backend): items از `response?.data` و total از `response?.meta?.totalRecords`/`totalPages`. کد فعلی `response.page.totalPages` میخواند — با شکل واقعی (`meta`) همخوان کن:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (Array.isArray(response?.data)) {
|
||||||
|
setAppointments(response.data); // یا setPayments
|
||||||
|
setTotalPages(response?.meta?.totalPages || 1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **قرارداد دقیق پاسخ را از پرامپت/داک backend بگیر** (`docs/api/payment.md` و `docs/api/appointment.md` بخش my/*). اگر `my/appointments` برای نقش کاربرِ ساده خالی برمیگرداند، با backend چک کن کدام endpoint نوبتهای خود بیمار را میدهد (شاید `GET /api/v1/appointments/user`). **اگر مبهم بود متوقف شو و بپرس.**
|
||||||
|
|
||||||
|
### ۵. (در صورت لزوم) `app/dashboard/page.js`
|
||||||
|
|
||||||
|
- حالا که کوکی `uuid` = uuid کاربر است، `fetchReq(/user-profile/{uuid})` با endpoint اصلاحشدهی backend (resolve با user-uuid + lazy-create) ۲۰۰ میدهد. تأیید کن `buildPatientUser(profile)` با شکل پاسخ (`{success, data:{data:{...}}}` → `profile.data.data`) همخوان است؛ اگر `buildPatientUser` از `profile?.data` میخواند ولی پاسخ دوبار تودرتو است، عمق استخراج را اصلاح کن.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **وابستگی cross-repo:** تب تراکنشها به `GET /api/v1/my/payments` جدید نیاز دارد؛ اگر نبود اول backend را اجرا کن.
|
||||||
|
- **امنیت/درستی:** endpointهای `my/*` کاربر را از توکن میگیرند؛ دیگر `userId` در URL نفرست.
|
||||||
|
- **شکل پاسخ:** `paginated()` → `data` آرایه، `meta.totalRecords`/`meta.totalPages`. این با `success(['data'=>...])` (دوبار تودرتو) فرق دارد — هرکدام را درست مصرف کن.
|
||||||
|
- **کوکی uuid:** بعد از این تغییر، همهی مصرفکنندههای کوکی `uuid` (dashboard/page.js, SubmitData.js, lib/auth.js) uuid کاربر میگیرند — درست.
|
||||||
|
- RTL/Jalali/multi-domain حفظ شوند؛ توهمسازی داده نکن (لیست خالی = پیام «موردی نیست»).
|
||||||
|
- **تست:** `npm run build`؛ سپس دستی با کاربر `09210651788`: داشبورد بدون ۴۰۴/crash لود شود؛ هدر نام کاربر یا «کاربر» را نشان دهد؛ تب نوبتها و تراکنشها بدون خطا (لیست خالی یا واقعی). سپس commit.
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# رفع نمایش نوبت آزاد و دکمه نوبت برای پزشک با نوبتدهی غیرفعال
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی — فقط نمایش؛ backend از قبل `free_turn` و `active` صحیح برمیگرداند)
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
Backend (`clinicpro`) برای هر پزشک دو فیلد میفرستد:
|
||||||
|
- `active` (boolean) = `activeDoctorAppointment && has_schedule`
|
||||||
|
- `free_turn` (string) = نزدیکترین روز/ساعت کاری (مثل `دوشنبه 09:00–13:00`)، یا اگر نوبتدهی آنلاین خاموش باشد `"نوبتدهی آنلاین غیرفعال است"`.
|
||||||
|
|
||||||
|
سایت عمومی این دو فیلد را در سه جا نمایش میدهد و الان رفتارش ناقص است.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
۱. در کارت لیست `/doctors` و در باکس نوبتدهی صفحهی پزشک، متن نوبت آزاد باید با پیشوند **«اولین نوبت آزاد:»** نمایش داده شود (مثلاً `اولین نوبت آزاد: دوشنبه 09:00–13:00`).
|
||||||
|
۲. اگر پزشک نوبتدهی آنلاینش خاموش است (`active === false`)، بهجای `free_turn` باید **«نوبتدهی غیرفعال است»** نوشته شود.
|
||||||
|
۳. در صفحهی پزشک (`/doctor/{uuid}`) بخش «نوبت دهی»، اگر `active === false` دکمهی **«دریافت نوبت»** باید **disabled** شود (نه لینک فعال به `/appointment`).
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `app/component/ItemDoctor.js` | کارت پزشک در لیست `/doctors` — متن `free_turn` |
|
||||||
|
| `components/doctor/appointmentList/ItemAppointment.js` | باکس نوبتدهی دسکتاپ صفحه پزشک — متن + دکمه |
|
||||||
|
| `components/doctor/appointmentList/index.js` | نوار نوبتدهی موبایل (`fixed bottom`) — متن + دکمه |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
### `app/component/ItemDoctor.js` (حوالی خط ۸۴)
|
||||||
|
```jsx
|
||||||
|
<CustomLoading loading={loading} width={150} height={20}>
|
||||||
|
<p
|
||||||
|
className={`text-[12px] font-medium rounded w-fit p-1.5 ml-[52px]
|
||||||
|
${Number(doctor?.active)
|
||||||
|
? "bg-[rgba(5,_186,_88,_0.04)] text-[#05BA58]"
|
||||||
|
: "bg-[rgba(211,_47,_47,_0.04)] text-[#D32F2F]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{doctor?.free_turn}
|
||||||
|
</p>
|
||||||
|
</CustomLoading>
|
||||||
|
```
|
||||||
|
مشکل: متن خام `free_turn` بدون پیشوند «اولین نوبت آزاد».
|
||||||
|
|
||||||
|
### `components/doctor/appointmentList/ItemAppointment.js` (خط ۳۶–۵۸)
|
||||||
|
```jsx
|
||||||
|
<div className="flex mt-5 gap-2 items-center justify-between">
|
||||||
|
<div className="flex items-start justify-start gap-1">
|
||||||
|
<GreenCalendarTurn />
|
||||||
|
<TextLoading width={100} height={15} loading={loading}>
|
||||||
|
<p className="text-[#05BA58] text-[12px] font-medium">
|
||||||
|
{turn?.free_turn
|
||||||
|
? ` ${turn?.free_turn}`
|
||||||
|
: "نوبت ندارد"}
|
||||||
|
</p>
|
||||||
|
</TextLoading>
|
||||||
|
</div>
|
||||||
|
<Link href={loading ? "#" : `/appointment/${doctorSlug}`}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
className="!py-2 !px-3 xl:!px-6 gap-1"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
دریافت نوبت
|
||||||
|
<div className="w-[20px] h-[20px]">
|
||||||
|
<ArrowLeftD />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
مشکل: پیشوند ندارد؛ دکمه فقط با `loading` غیرفعال میشود، نه با `active === false`.
|
||||||
|
|
||||||
|
### `components/doctor/appointmentList/index.js` (نوار موبایل، خط ۱۶–۳۴)
|
||||||
|
```jsx
|
||||||
|
<div className="fixed lg:hidden ... flex items-center justify-between">
|
||||||
|
<p className="text-[#05BA58] text-[11px] font-normal">
|
||||||
|
{doctor && doctor?.free_turn}
|
||||||
|
</p>
|
||||||
|
<Link href={`/appointment/${doctorSlug}`}>
|
||||||
|
<Button variant="contained" className="!py-2 !px-4 gap-1 !text-[14px] !font-medium">
|
||||||
|
دریافت نوبت
|
||||||
|
<div className="w-[20px] h-[20px]"><ArrowLeftD /></div>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
مشکل: پیشوند ندارد؛ دکمه هیچوقت با `active === false` غیرفعال نمیشود.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. کارت لیست — `app/component/ItemDoctor.js`
|
||||||
|
|
||||||
|
متن `free_turn` را با پیشوند نشان بده؛ اگر `active` خاموش بود متن «نوبتدهی غیرفعال است».
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{Number(doctor?.active) && doctor?.free_turn
|
||||||
|
? `اولین نوبت آزاد: ${doctor.free_turn}`
|
||||||
|
: "نوبتدهی غیرفعال است"}
|
||||||
|
```
|
||||||
|
رنگ سبز/قرمز موجود حفظ شود (بر اساس `Number(doctor?.active)`).
|
||||||
|
|
||||||
|
### ۲. باکس دسکتاپ — `components/doctor/appointmentList/ItemAppointment.js`
|
||||||
|
|
||||||
|
اول یک متغیر در ابتدای کامپوننت:
|
||||||
|
```jsx
|
||||||
|
function ItemAppointment({ turn, loading, doctorSlug }) {
|
||||||
|
const bookingDisabled = !loading && turn?.active === false;
|
||||||
|
return (
|
||||||
|
```
|
||||||
|
|
||||||
|
متن:
|
||||||
|
```jsx
|
||||||
|
<p className="text-[#05BA58] text-[12px] font-medium">
|
||||||
|
{bookingDisabled || !turn?.free_turn
|
||||||
|
? "نوبتدهی غیرفعال است"
|
||||||
|
: `اولین نوبت آزاد: ${turn.free_turn}`}
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
دکمه — وقتی `bookingDisabled` بود disabled و بدون `<Link>`:
|
||||||
|
```jsx
|
||||||
|
{bookingDisabled ? (
|
||||||
|
<Button variant="contained" className="!py-2 !px-3 xl:!px-6 gap-1" disabled>
|
||||||
|
نوبتدهی غیرفعال
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Link href={loading ? "#" : `/appointment/${doctorSlug}`}>
|
||||||
|
<Button variant="contained" className="!py-2 !px-3 xl:!px-6 gap-1" disabled={loading}>
|
||||||
|
دریافت نوبت
|
||||||
|
<div className="w-[20px] h-[20px]"><ArrowLeftD /></div>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳. نوار موبایل — `components/doctor/appointmentList/index.js`
|
||||||
|
|
||||||
|
متن:
|
||||||
|
```jsx
|
||||||
|
<p className="text-[#05BA58] text-[11px] font-normal">
|
||||||
|
{doctor?.active === false || !doctor?.free_turn
|
||||||
|
? "نوبتدهی غیرفعال است"
|
||||||
|
: `اولین نوبت آزاد: ${doctor.free_turn}`}
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
دکمه:
|
||||||
|
```jsx
|
||||||
|
{doctor?.active === false ? (
|
||||||
|
<Button variant="contained" className="!py-2 !px-4 gap-1 !text-[14px] !font-medium" disabled>
|
||||||
|
نوبتدهی غیرفعال
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Link href={`/appointment/${doctorSlug}`}>
|
||||||
|
<Button variant="contained" className="!py-2 !px-4 gap-1 !text-[14px] !font-medium">
|
||||||
|
دریافت نوبت
|
||||||
|
<div className="w-[20px] h-[20px]"><ArrowLeftD /></div>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- `active` از API نوع boolean است؛ در `ItemDoctor.js` با `Number(doctor?.active)` چک میشود (الگوی موجود را نگهدار)، در باقی جاها مستقیم `=== false`.
|
||||||
|
- در حالت `loading` (اسکلتون) نباید «نوبتدهی غیرفعال» نشان داده شود؛ برای همین `bookingDisabled = !loading && turn?.active === false`.
|
||||||
|
- backend از قبل پیام `"نوبتدهی آنلاین غیرفعال است"` در `free_turn` میفرستد — ولی frontend نباید به متن backend وابسته باشد؛ بر اساس فیلد boolean `active` تصمیم بگیر (پایدارتر).
|
||||||
|
- تست با پزشک `7db9be49-db71-4ec5-add6-24f890b4ad29` (در DB محلی باید `active_doctor_appointment=0` شود تا غیرفعال شود). پزشک غیرفعال در لیست `/doctors` اصلاً نمیآید (فیلتر backend)، پس باگ کارت لیست فقط برای پزشکانی که `active:false` ولی همچنان در نتیجه هستند مهم است.
|
||||||
|
- بعد از تغییر: `npm run build` بدون خطا.
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# رفع مشکل عکس نادرست در پوستر دانلودی صفحه پزشک (اشتراکگذاری)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
در صفحهی عمومی پزشک (`/doctor/[slug]`) یک دکمهی «اشتراک گذاری» هست که یک Modal باز میکند. داخل Modal گزینهی «دانلود» یک پوستر PDF میسازد. پوستر شامل عکس پروفایل پزشک، QR کد، لوگو و اطلاعات پزشک است.
|
||||||
|
|
||||||
|
مسیر تست:
|
||||||
|
`http://localhost:3000/doctor/f58d3dba-6ce0-4bd4-85d9-9d787a0b9324` → «اشتراک گذاری» → «دانلود»
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
وقتی روی «دانلود» کلیک میشود، پوستر PDF دانلود میشود ولی **عکس آن درست نیست** — عکس پروفایل پزشک و/یا QR کد در فایل خروجی خالی/خراب/ناقص است.
|
||||||
|
|
||||||
|
**علت ریشهای:** `handleDownloadPDF` بلافاصله `html2canvas` را صدا میزند بدون اینکه منتظر بارگذاری کامل تصاویر بماند:
|
||||||
|
|
||||||
|
1. عکس پروفایل با `next/image` (`<Image>`) رندر میشود که از دامنهی remote (`api.clinic-pro.ir`) از طریق optimizer آدرس `/_next/image?url=...` میآید و lazy load دارد؛ ممکن است هنگام capture هنوز paint نشده باشد.
|
||||||
|
2. QR کد بهصورت async در `useEffect` تولید میشود (`QRCode.toDataURL(...).then(setQrCodeData)`)؛ اگر کاربر سریع دانلود بزند، بهجای QR متن «در حال بارگذاری...» در پوستر میافتد.
|
||||||
|
3. هیچ await برای اتمام لود شدن `<img>`ها قبل از snapshot وجود ندارد.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/doctor/detailDoctor/Share.js` | Modal اشتراکگذاری؛ Poster را در یک div مخفی (`hiddenRef`) رندر میکند |
|
||||||
|
| `components/doctor/detailDoctor/List.js` | آیکونهای اشتراک + `handleDownloadPDF` (منطق html2canvas + jsPDF) — **هستهی باگ** |
|
||||||
|
| `components/doctor/poster/index.js` | چیدمان پوستر |
|
||||||
|
| `components/doctor/poster/ImageProfile.js` | عکس پروفایل با `next/image` |
|
||||||
|
| `components/doctor/poster/QrImg.js` | تولید async QR کد |
|
||||||
|
| `helper/index.js` | `imageUrl(url, fallback)` |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
### `components/doctor/detailDoctor/List.js` (بخش دانلود)
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const handleDownloadPDF = async () => {
|
||||||
|
const element = hiddenRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const canvas = await html2canvas(element, {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
useCORS: true,
|
||||||
|
scale: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imgData = canvas.toDataURL("image/png");
|
||||||
|
// ... jsPDF ...
|
||||||
|
pdf.save("poster.pdf");
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### `components/doctor/poster/ImageProfile.js`
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import Image from "next/image";
|
||||||
|
import { imageUrl } from "@/helper";
|
||||||
|
|
||||||
|
function ImageProfile({ data }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-stroke-circle ml-[10px] mt-[57px] p-[9px] rounded-full overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={imageUrl(data?.img?.[0]?.url)}
|
||||||
|
width={173}
|
||||||
|
height={173}
|
||||||
|
alt="profile-user"
|
||||||
|
className="rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `components/doctor/poster/QrImg.js` (خلاصه)
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
useEffect(() => {
|
||||||
|
const info = getStateInfoClient();
|
||||||
|
setHost(info.host);
|
||||||
|
if (info.fullUrl) QRCode.toDataURL(info.fullUrl).then(setQrCodeData);
|
||||||
|
}, []);
|
||||||
|
// ...
|
||||||
|
{qrCodeData ? <img src={qrCodeData} .../> : <p>در حال بارگذاری...</p>}
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. منتظر بارگذاری کامل همهی `<img>`ها بمان قبل از `html2canvas`
|
||||||
|
|
||||||
|
در `List.js` یک helper اضافه کن که همهی `<img>`های داخل `element` را پیدا کند و تا `complete` شدن (و در صورت امکان `decode()`) منتظر بماند. سپس در `handleDownloadPDF` قبل از `html2canvas` آن را await کن.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const waitForImages = async (element) => {
|
||||||
|
const imgs = Array.from(element.querySelectorAll("img"));
|
||||||
|
await Promise.all(
|
||||||
|
imgs.map((img) => {
|
||||||
|
if (img.complete && img.naturalWidth !== 0) {
|
||||||
|
return img.decode?.().catch(() => {}) ?? Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = () => resolve(); // خراب بودن یک عکس نباید کل دانلود را بلاک کند
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
در `handleDownloadPDF`:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const element = hiddenRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
await waitForImages(element);
|
||||||
|
|
||||||
|
const canvas = await html2canvas(element, {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
useCORS: true,
|
||||||
|
scale: 2,
|
||||||
|
imageTimeout: 15000,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. اطمینان از آماده بودن QR قبل از دانلود
|
||||||
|
|
||||||
|
اگر QR هنوز تولید نشده باشد، پوستر متن «در حال بارگذاری...» را نشان میدهد. راهحل ساده و مطمئن: دکمهی دانلود تا آماده شدن QR غیرفعال/در حالت لودینگ باشد.
|
||||||
|
|
||||||
|
- در `QrImg.js` یک callback مثل `onReady` بپذیر و بعد از `setQrCodeData` صدا بزن؛ این وضعیت را تا `Share.js` بالا ببر و به `List.js` بده، یا
|
||||||
|
- سادهتر: در `handleDownloadPDF` بعد از `waitForImages`، اگر داخل `element` عنصری با متن «در حال بارگذاری...» بود، چند صد میلیثانیه polling کوتاه انجام بده تا `img` مربوط به QR ظاهر شود (سقف زمانی بگذار).
|
||||||
|
|
||||||
|
رویکرد اول (state واقعی) تمیزتر است؛ آن را ترجیح بده.
|
||||||
|
|
||||||
|
### ۳. سازگاری عکس پروفایل با html2canvas
|
||||||
|
|
||||||
|
عکس پروفایل با `next/image` از طریق `/_next/image` (same-origin) سرو میشود، پس مشکل CORS نباید داشته باشد و **نیازی به تبدیل به `<img>` خام نیست**. اگر بعد از وظیفهی ۱ باز هم عکس پروفایل در خروجی خالی بود:
|
||||||
|
|
||||||
|
- fallback `imageUrl` مقدار `/assets/images/user.png` (local) را برمیگرداند که همیشه قابل رندر است — این را نگهدار.
|
||||||
|
- در صورت نیاز `priority` یا `unoptimized` به `<Image>` اضافه کن تا lazy load حذف شود و از همان ابتدا لود شود:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Image
|
||||||
|
src={imageUrl(data?.img?.[0]?.url)}
|
||||||
|
width={173}
|
||||||
|
height={173}
|
||||||
|
alt="profile-user"
|
||||||
|
className="rounded-full"
|
||||||
|
priority
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
> `unoptimized` باعث میشود آدرس مستقیم عکس (بدون `/_next/image`) رندر شود؛ اگر دامنهی remote هدر CORS نفرستد ممکن است canvas آلوده شود. اگر این حالت را انتخاب کردی حتماً خروجی را تست کن؛ در غیر این صورت فقط `priority` کافی است.
|
||||||
|
|
||||||
|
### ۴. UX دکمهی دانلود
|
||||||
|
|
||||||
|
- هنگام تولید پوستر، دکمهی «دانلود» را در حالت loading/disabled بگذار تا کلیک دوباره منجر به دانلودهای موازی نشود.
|
||||||
|
- در صورت throw شدن `html2canvas`، با `toast.error(...)` (فارسی) خطا بده و state را ریست کن. `react-toastify` از قبل import شده.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- Poster در `Share.js` داخل یک div با `opacity-0 pointer-events-none -z-10` ولی با ابعاد ثابت `600x700` و در `top-0 left-0` رندر میشود (یعنی داخل viewport است و لود میشود). این ساختار را نگهدار — html2canvas به عنصر رندرشدهی واقعی نیاز دارد؛ `display:none` نکن.
|
||||||
|
- نسخهها: `html2canvas@^1.4.1`، `jspdf@^3.0.4`. Tailwind نسخهی ۳ است (رنگها rgb هستند، مشکل oklch نداریم).
|
||||||
|
- کلاسهای `bg-poster` و `bg-stroke-circle` در `globals.css` از تصاویر local (`public/assets/images/...`) بهعنوان `background-image` استفاده میکنند؛ html2canvas background-image را رندر میکند و چون local هستند مشکلی ندارند.
|
||||||
|
- همهی رشتههای جدید فارسی باشند.
|
||||||
|
- بعد از تغییر، سناریو را واقعی تست کن: باز کردن Modal، کلیک روی دانلود، و باز کردن PDF خروجی برای تأیید حضور عکس پروفایل + QR + لوگو.
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# فیکس کندی و باگهای فیلتر صفحه پزشکان
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
صفحه `/doctors` بعد از `npm run build && npm run start` (production mode) سه مشکل دارد:
|
||||||
|
۱. سرعت کلی سایت کند میشود
|
||||||
|
۲. مودال انتخاب شهر/استان بعد از کلیک «تایید و ادامه» دیر بسته میشود و فیلتر با تاخیر اعمال میشود
|
||||||
|
۳. دستهبندی (category/specialty) در مودال فیلتر درست کار نمیکند
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `nobat724_front/app/component/modalSearchCity/Content.js` | مودال انتخاب شهر/استان — دکمه «تایید و ادامه» |
|
||||||
|
| `nobat724_front/app/component/modalSearchCity/index.js` | wrapper مودال شهر |
|
||||||
|
| `nobat724_front/components/doctors/modal/ButtonApply.js` | دکمه «اعمال تغییرات» در مودال فیلتر |
|
||||||
|
| `nobat724_front/components/doctors/modal/Content.js` | محتوای مودال فیلتر (category/specialty) |
|
||||||
|
| `nobat724_front/components/doctors/modal/form/index.js` | فرم فیلتر — CateSelector برای category و specialty |
|
||||||
|
| `nobat724_front/components/doctors/head/index.js` | `sendReq` — تابع اصلی fetch |
|
||||||
|
| `nobat724_front/helper/index.js` (خط ۴۲۰) | `QueryForDoctorsReq` — تبدیل filter به params |
|
||||||
|
| `nobat724_front/helper/index.js` (خط ۲۱۵) | `filterList` — تولید parentList و childrenList |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
### باگ ۱ — مودال شهر/استان دیر بسته میشود
|
||||||
|
|
||||||
|
`Content.js` خط ۲۴: `getStateInfoClient()` در هر render صدا زده میشود:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// app/component/modalSearchCity/Content.js
|
||||||
|
const { matchedState } = getStateInfoClient();
|
||||||
|
```
|
||||||
|
|
||||||
|
و دکمه «تایید و ادامه» منتظر `sendReq()` میماند قبل از بستن:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const handleClick = () => {
|
||||||
|
setLoading(true);
|
||||||
|
sendReq().finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
handleClose(); // ← مودال فقط بعد از پایان fetch بسته میشود
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`sendReq` در `head/index.js` یک HTTP request کامل است — تا پایان fetch مودال باز میماند.
|
||||||
|
|
||||||
|
همچنین دو `disabled` prop روی دکمه تایید وجود دارد (یکی `loading` و یکی `!filter?.city || !filter?.state`) که در JSX اشتباه است — دومی override میکند.
|
||||||
|
|
||||||
|
### باگ ۲ — دستهبندی کار نمیکند
|
||||||
|
|
||||||
|
در `modal/Content.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function Content({ filter, setFilter, updateData, setDataInURL }) {
|
||||||
|
const changeSpecialty = (name, value) => {
|
||||||
|
const newFilter = { ...filter, [name]: value };
|
||||||
|
|
||||||
|
if (name === "category") {
|
||||||
|
const childrenList = specialties.filter((item) => item.parent);
|
||||||
|
const filteredChildrenList = childrenList.filter(
|
||||||
|
(item) => item.parent === value.id // ← value میتواند null باشد (وقتی clear میشود)
|
||||||
|
);
|
||||||
|
const firstChildren = filteredChildrenList[0];
|
||||||
|
newFilter.specialty = firstChildren || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilter(newFilter);
|
||||||
|
setDataInURL(newFilter);
|
||||||
|
return newFilter;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
وقتی `value` (category) null است (کاربر انتخاب را پاک میکند)، `value.id` crash میکند.
|
||||||
|
|
||||||
|
و `filterList` در `helper/index.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const filterList = (data) => {
|
||||||
|
const parentList = specialties.filter((item) => !item.parent);
|
||||||
|
const childrenList = specialties.filter((item) => item.parent);
|
||||||
|
|
||||||
|
const filteredChildrenList =
|
||||||
|
data && data.category
|
||||||
|
? childrenList.filter((item) => item.parent === data.category.id)
|
||||||
|
: [];
|
||||||
|
// ← item.parent (string از JSON) vs data.category.id (ممکن است number باشد)
|
||||||
|
```
|
||||||
|
|
||||||
|
`specialties.json` فیلد `parent` را بهصورت string ذخیره میکند (مثلاً `"1"`) اما `category.id` ممکن است number باشد — type mismatch.
|
||||||
|
|
||||||
|
### باگ ۳ — کندی کلی
|
||||||
|
|
||||||
|
احتمال اصلی: `getStateInfoClient()` و `cities.filter()` و `states.find()` در هر render اجرا میشوند بدون memoization. همچنین `sendReq` در `ButtonApply` و `Content` هر دو منتظر کامل شدن fetch قبل از بستن مودال هستند.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. فیکس بستن سریع مودال شهر/استان
|
||||||
|
|
||||||
|
در `app/component/modalSearchCity/Content.js` مودال را فوری ببند و fetch در پسزمینه ادامه یابد:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const handleClick = () => {
|
||||||
|
handleClose(); // فوری ببند
|
||||||
|
sendReq(); // fetch در background
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
همچنین دو `disabled` را روی دکمه یکی کن:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Button
|
||||||
|
disabled={!filter?.city || !filter?.state}
|
||||||
|
onClick={handleClick}
|
||||||
|
variant="contained"
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. فیکس بستن سریع مودال فیلتر
|
||||||
|
|
||||||
|
در `components/doctors/modal/ButtonApply.js` همین الگو را اعمال کن:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const handleClick = () => {
|
||||||
|
setOpen(false); // فوری ببند
|
||||||
|
sendReq(); // fetch در background
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
state `loading` را میتوانی حذف کنی چون دیگر منتظر fetch نمیماند.
|
||||||
|
|
||||||
|
### ۳. فیکس crash دستهبندی هنگام clear
|
||||||
|
|
||||||
|
در `components/doctors/modal/Content.js` خط مربوط به `value.id`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const changeSpecialty = (name, value) => {
|
||||||
|
const newFilter = { ...filter, [name]: value };
|
||||||
|
|
||||||
|
if (name === "category") {
|
||||||
|
if (value) {
|
||||||
|
const filteredChildrenList = specialties
|
||||||
|
.filter((item) => item.parent)
|
||||||
|
.filter((item) => String(item.parent) === String(value.id)); // ← String() برای type safety
|
||||||
|
newFilter.specialty = filteredChildrenList[0] || null;
|
||||||
|
} else {
|
||||||
|
newFilter.specialty = null; // clear category → clear specialty هم
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilter(newFilter);
|
||||||
|
setDataInURL(newFilter);
|
||||||
|
return newFilter;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۴. فیکس type mismatch در filterList
|
||||||
|
|
||||||
|
در `helper/index.js` خط ۲۱۹:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const filteredChildrenList =
|
||||||
|
data && data.category
|
||||||
|
? childrenList.filter(
|
||||||
|
(item) => String(item.parent) === String(data.category.id)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۵. بررسی کندی کلی
|
||||||
|
|
||||||
|
- در `app/component/modalSearchCity/index.js`، `useEffect` وابسته به `stateSelected` هر بار کل `cities` را filter میکند — با `useMemo` بهینه کن:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const filteredCities = useMemo(() => {
|
||||||
|
if (!stateSelected) return [];
|
||||||
|
const selectedState = states.find((s) => s.name === stateSelected);
|
||||||
|
if (!selectedState) return [];
|
||||||
|
return cities.filter((c) => c.province_id === selectedState.id);
|
||||||
|
}, [stateSelected]);
|
||||||
|
// حذف useState و useEffect مربوطه
|
||||||
|
```
|
||||||
|
|
||||||
|
- `getStateInfoClient()` در `Content.js` را به بیرون از component ببر یا با `useMemo` cache کن.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- `specialties.json` فیلد `parent` را string ذخیره میکند (`"1"` نه `1`) — همیشه `String()` برای مقایسه استفاده کن
|
||||||
|
- مودالها باید فوری بسته شوند (UX) و fetch در background ادامه یابد — کاربر نباید منتظر پاسخ API بماند
|
||||||
|
- `sendReq` در `head/index.js` یک Promise برمیگرداند — میتوان بدون `await` صدا زد
|
||||||
|
- `disabled` دو بار روی یک `<Button>` در JSX: دومی اول override میکند — فقط یکی نگهدار
|
||||||
|
- بعد از تغییر، با `npm run build && npm run start` تست کن (نه dev mode)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# رفع نمایش «ورود | ثبتنام» در هدر با وجود لاگینبودن کاربر
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — سایت عمومی. این کار **صرفاً frontend** است؛ backend تغییر نمیکند.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
هدر سایت عمومی بسته به وضعیت لاگین، یا دکمهی «ورود | ثبت نام» را نشان میدهد یا آیکن پروفایل کاربر (`ProfileUser`). تصمیم بر اساس prop `logged` گرفته میشود که در `components/layout/StLayout.js` (Server Component) از روی کوکی `access_token` با `cookies()` خوانده و به هدر پاس داده میشود.
|
||||||
|
|
||||||
|
مشکل: کاربر بعد از لاگین موفق (کوکیهای `access_token`/`refresh_token`/`uuid`/`userInfo` با `js-cookie` ست میشوند)، **هنوز در هدر «ورود | ثبت نام» میبیند**. علت: `logged` فقط یکبار سمت سرور هنگام render محاسبه میشود و به ناوبریهای client-side (soft navigation) و کوکیهایی که سمت کلاینت ست شدهاند واکنش نشان نمیدهد؛ هدر تا یک hard-reload کامل وضعیت تازه را منعکس نمیکند و در بسیاری از مسیرها همان حالت اولیهی «لاگیننشده» باقی میماند.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
هدر باید وضعیت لاگین را **بهصورت زنده سمت کلاینت** هم تشخیص دهد: اگر کوکی `access_token` وجود دارد → پروفایل، وگرنه → دکمهی ورود. prop سرور (`logged`) بهعنوان مقدار اولیهی paint اول حفظ شود تا flash/hydration mismatch ندهد، ولی منبع نهایی تصمیم، کوکی سمت کلاینت باشد.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/layout/header/Content.js` | `"use client"`؛ بر اساس `logged` بین `ProfileUser` و دکمهی ورود سوییچ میکند — محل اصلی رفع |
|
||||||
|
| `components/layout/StLayout.js` | Server Component؛ `logged={token}` را از `cookies().get("access_token")` میدهد (مقدار اولیه) |
|
||||||
|
| `components/layout/index.js` | layout کلاینتیِ پنل/داشبورد؛ `logged` را بهصورت prop میگیرد |
|
||||||
|
| `components/layout/header/index.js` | فقط prop را پاس میدهد |
|
||||||
|
| `components/layout/header/profile/index.js` | `ProfileUser` — حالت لاگینشده |
|
||||||
|
| `utils/index.js` | `removeToken()` — خروج |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
### `header/Content.js` — تصمیم فقط بر اساس prop سرور
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
"use client";
|
||||||
|
// ...
|
||||||
|
function Content({ matchedCity, name, logged }) {
|
||||||
|
// ...
|
||||||
|
{logged ? (
|
||||||
|
<ProfileUser />
|
||||||
|
) : (
|
||||||
|
<Link href="/login">
|
||||||
|
<Button variant="outlined" color="primary">ورود | ثبت نام</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `StLayout.js` — مقدار اولیه از کوکی سرور
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get("access_token");
|
||||||
|
// ...
|
||||||
|
<Header matchedCity={matchedCity} name={name} logged={token} />
|
||||||
|
```
|
||||||
|
|
||||||
|
> `token` یک شیء `{ name, value }` یا `undefined` است؛ پس `logged` truthy/undefined میشود. این فقط در render سرور محاسبه میشود.
|
||||||
|
|
||||||
|
### کوکیها سمت کلاینت ست میشوند (`SendReq.js`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
Cookies.set("access_token", response.access_token, cookieOptions); // js-cookie, غیر httpOnly
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. تشخیص زندهی لاگین در `header/Content.js`
|
||||||
|
|
||||||
|
- یک state کلاینتی برای لاگینبودن اضافه کن که مقدار اولیهاش از prop `logged` (سرور) میآید تا paint اول درست باشد، سپس در `useEffect` با کوکی `access_token` همگام شود:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
// ...
|
||||||
|
function Content({ matchedCity, name, logged }) {
|
||||||
|
const [isLogged, setIsLogged] = useState(Boolean(logged));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLogged(Boolean(Cookies.get("access_token")));
|
||||||
|
}, []);
|
||||||
|
// ...
|
||||||
|
{isLogged ? <ProfileUser /> : <Link href="/login">...دکمه ورود...</Link>}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Boolean(logged)` مهم است: `logged` ممکن است شیء `{name,value}` یا `undefined` باشد؛ تبدیل به boolean کن.
|
||||||
|
- چون مقدار اولیه از سرور میآید، hydration mismatch رخ نمیدهد؛ بعد از mount با کوکی واقعی کلاینت تصحیح میشود.
|
||||||
|
|
||||||
|
### ۲. واکنش به تغییر مسیر (اختیاری ولی توصیهشده)
|
||||||
|
|
||||||
|
- اگر لازم بود وضعیت بعد از login/logout بدون hard-reload بهروز شود، میتوانی `usePathname()` را به deps افزودن useEffect اضافه کنی تا با هر تغییر مسیر کوکی دوباره چک شود:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
const pathname = usePathname();
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLogged(Boolean(Cookies.get("access_token")));
|
||||||
|
}, [pathname]);
|
||||||
|
```
|
||||||
|
|
||||||
|
> اگر فلوی فعلی login با `window.location.href` ریدایرکت میکند (hard reload)، paint اول سرور هم درست خواهد بود؛ این مرحله بیشتر برای ناوبریهای soft و logout است.
|
||||||
|
|
||||||
|
### ۳. سازگاری logout
|
||||||
|
|
||||||
|
- `ProfileUser`/`DetailProfile` هنگام logout `removeToken()` را صدا میزنند که کوکیها را پاک میکند. مطمئن شو بعد از logout، هدر دوباره دکمهی ورود را نشان میدهد (با همان مکانیزم کوکیخوانی + ناوبری/ریلود فعلی). اگر logout فقط state را عوض میکند و ریدایرکت ندارد، بررسی کن هدر بهروز شود.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **هیچ تغییری در backend لازم نیست.** کوکی `access_token` غیر httpOnly است و سمت کلاینت با `js-cookie` قابلخواندن است.
|
||||||
|
- **Hydration:** مقدار اولیهی state حتماً از prop `logged` سرور باشد، نه مستقیماً از `Cookies.get` در زمان render؛ خواندن کوکی فقط داخل `useEffect` (بعد از mount) تا SSR و کلاینت در paint اول یکی باشند.
|
||||||
|
- **multi-domain / RTL** را خراب نکن؛ منطق `matchedCity`/`name` دستنخورده بماند.
|
||||||
|
- این هدر هم در `StLayout` (صفحات عمومی، logged از سرور) و هم در `components/layout/index.js` (پنل/داشبورد، logged از prop) استفاده میشود؛ هر دو مسیر باید بعد از تغییر کار کنند (در هر دو، fallback به prop + همگامسازی با کوکی درست عمل میکند).
|
||||||
|
- **تست:** `npm run build`؛ سپس دستی: قبل از لاگین → دکمهی «ورود | ثبت نام»؛ بعد از لاگین با `12345` و بازگشت به صفحه → آیکن پروفایل؛ بعد از logout → دوباره دکمهی ورود. سپس commit با پیام توصیفی.
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# فیکس دو باگ در فلو login
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
دو باگ مستقل در فلو login/auth وجود دارد. یکی نمایشی (شماره اضافه) و یکی عملکردی (redirect به /login بعد از refresh صفحه).
|
||||||
|
|
||||||
|
## باگ ۱ — شماره موبایل با `09` اضافی
|
||||||
|
|
||||||
|
### مشکل
|
||||||
|
|
||||||
|
در `VerificationPage` پیام «کد تایید برای شماره X ارسال شد» نمایش میدهد `0909xxxxxxxxx` به جای `09xxxxxxxxx`.
|
||||||
|
|
||||||
|
### علت
|
||||||
|
|
||||||
|
`components/register/verificationPage/index.js` خط ۵۹:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
کد تایید برای شماره <span dir="ltr">09{num}</span> ارسال شد.
|
||||||
|
```
|
||||||
|
|
||||||
|
اما `num` از state والد (`RegisterPage`) میآید که کل شماره را نگه میدارد (شامل `09`). `validatePhoneNumber` در `helper/index.js` شماره را با `^09\d{9}$` validate میکند — پس `num` قبلاً `09` دارد. hard-code کردن `09` اضافه → double prefix.
|
||||||
|
|
||||||
|
### فیکس
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// فعلی (اشتباه):
|
||||||
|
کد تایید برای شماره <span dir="ltr">09{num}</span> ارسال شد.
|
||||||
|
|
||||||
|
// درست:
|
||||||
|
کد تایید برای شماره <span dir="ltr">{num}</span> ارسال شد.
|
||||||
|
```
|
||||||
|
|
||||||
|
## باگ ۲ — redirect به `/login` بعد از refresh صفحه
|
||||||
|
|
||||||
|
### مشکل
|
||||||
|
|
||||||
|
بعد از login موفق و رفتن به `/dashboard`، refresh صفحه → redirect به `/login`.
|
||||||
|
|
||||||
|
### علت احتمالی
|
||||||
|
|
||||||
|
`dashboard/page.js` خط ۲۲:
|
||||||
|
```js
|
||||||
|
if (!ability.can("access", "Dashboard") || !token) {
|
||||||
|
return redirect("/login");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
دو شرط میتواند fail شود:
|
||||||
|
|
||||||
|
**شرط اول** — `ability.can("access", "Dashboard")`:
|
||||||
|
`getUser()` در `lib/auth.js` چک میکند:
|
||||||
|
```js
|
||||||
|
const isAuthenticated = cookieStore.get("refresh_token") && cookieStore.get("userInfo");
|
||||||
|
```
|
||||||
|
`refresh_token` = HttpOnly (از `/api/auth/token` server route) → OK.
|
||||||
|
`userInfo` = js-cookie از client در `SendReq.js` → اگر این cookie درست سِت نشده باشد، `getUser()` null برمیگرداند.
|
||||||
|
|
||||||
|
**شرط دوم** — `!token`:
|
||||||
|
`getServerAccessToken()` از `refresh_token` HttpOnly token جدید میگیرد با `POST /oauth/token/refresh`. اگر این endpoint fail کند یا `refresh_token` expire شده باشد → `null` → redirect.
|
||||||
|
|
||||||
|
### فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/register/verificationPage/index.js` | خط ۵۹ — باگ ۱ |
|
||||||
|
| `components/register/verificationPage/SendReq.js` | سِت کردن cookies بعد از login |
|
||||||
|
| `app/api/auth/token/route.js` | سِت کردن `refresh_token` HttpOnly |
|
||||||
|
| `lib/auth.js` | `getUser()` — چک `refresh_token` + `userInfo` |
|
||||||
|
| `lib/serverToken.js` | `getServerAccessToken()` — refresh با `oauth/token/refresh` |
|
||||||
|
| `app/dashboard/page.js` | guard: `!ability || !token` → redirect |
|
||||||
|
| `lib/refreshCookie.js` | `setRefreshCookie()` |
|
||||||
|
|
||||||
|
### وضعیت فعلی
|
||||||
|
|
||||||
|
```js
|
||||||
|
// SendReq.js — بعد از login موفق
|
||||||
|
const getInfo = (token, cookieOptions) => {
|
||||||
|
request.getUserInfo(...).then((res) => {
|
||||||
|
const userInfo = { ...profile, username: profile.mobile_number };
|
||||||
|
Cookies.set("userInfo", JSON.stringify(userInfo), cookieOptions);
|
||||||
|
Cookies.set("uuid", profile.uuid, cookieOptions);
|
||||||
|
|
||||||
|
if (setStep) {
|
||||||
|
setStep(3);
|
||||||
|
} else {
|
||||||
|
window.location.href = "/"; // ← به / میرود، نه /dashboard
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// lib/serverToken.js
|
||||||
|
const res = await axiosInstance.post(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/oauth/token/refresh`,
|
||||||
|
{ refresh_token: refreshToken },
|
||||||
|
...
|
||||||
|
);
|
||||||
|
return res.data?.access_token ?? null;
|
||||||
|
```
|
||||||
|
|
||||||
|
### وظایف برای دیباگ و فیکس
|
||||||
|
|
||||||
|
#### ۱. فیکس باگ ۱ (قطعی)
|
||||||
|
|
||||||
|
در `components/register/verificationPage/index.js` خط ۵۹ `09` را حذف کن:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// قبل:
|
||||||
|
<span dir="ltr">09{num}</span>
|
||||||
|
// بعد:
|
||||||
|
<span dir="ltr">{num}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ۲. بررسی `userInfo` cookie
|
||||||
|
|
||||||
|
در `SendReq.js` بررسی کن `cookieOptions` درست است:
|
||||||
|
- در localhost: `domain` = undefined (✓ فعلاً چنین است)
|
||||||
|
- `sameSite: "lax"` در Next.js 15 App Router با server component ممکن است مشکل داشته باشد
|
||||||
|
|
||||||
|
اگر `userInfo` cookie سِت نمیشود، `getUser()` null برمیگرداند → `ability.can("access","Dashboard")` = false → redirect.
|
||||||
|
|
||||||
|
برای تشخیص: بعد از login در DevTools → Application → Cookies چک کن `userInfo` و `refresh_token` هر دو موجودند.
|
||||||
|
|
||||||
|
#### ۳. بررسی endpoint `/oauth/token/refresh`
|
||||||
|
|
||||||
|
در `lib/serverToken.js` چک کن آیا endpoint درست است:
|
||||||
|
```js
|
||||||
|
POST /oauth/token/refresh
|
||||||
|
body: { refresh_token: "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
با curl یا DevTools Network تأیید کن این endpoint در backend موجود است و `refresh_token` valid برمیگرداند. اگر ۴۰۱/۴۰۴ → `getServerAccessToken()` null برمیگرداند → `!token` → redirect.
|
||||||
|
|
||||||
|
#### ۴. redirect بعد از login
|
||||||
|
|
||||||
|
فعلاً `window.location.href = "/"` به صفحه خانه میرود. اگر هدف dashboard است:
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.location.href = "/dashboard";
|
||||||
|
```
|
||||||
|
|
||||||
|
اما اگر کوکیها درست سِت شده باشند، redirect به `/` و سپس دستی رفتن به `/dashboard` باید کار کند.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- `refresh_token` = HttpOnly → فقط از server قابل خواندن. از DevTools مستقیم قابل مشاهده نیست اما در Network tab response headers قابل دیدن است.
|
||||||
|
- `getUser()` هر دو `refresh_token` AND `userInfo` میخواهد — اگر فقط یکی موجود باشد، null برمیگرداند.
|
||||||
|
- `getServerAccessToken()` هر بار صفحه load میشود یک refresh token request میزند — اگر backend rate-limit داشته باشد یا endpoint اشتباه باشد، fail میشود.
|
||||||
|
- در DEV_MODE بررسی باگ ۲ را با Network tab و Console انجام بده.
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# رفع ماندن صفحهی لاگین بعد از وارد کردن کد OTP (نشست ساخته نمیشود)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — سایت عمومی. **این کار صرفاً frontend است؛ backend درست کار میکند** (با curl تأیید شد: `send-code` → uuid، `verify-code` با `12345` → ۲۰۰، `oauth/token` → توکن، `oauth/userinfo` → `{ success, data: {...} }`).
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
در صفحهی `/login`، کاربر شماره موبایل را وارد میکند، کد `12345` (کد ثابت محیط dev) را میزند، ولی **صفحه روی `/login` میماند** و وارد نمیشود. توکن گرفته میشود ولی نشست کامل نمیشود.
|
||||||
|
|
||||||
|
علت دقیق: بعد از گرفتن توکن، `components/register/verificationPage/SendReq.js` تابع `getInfo` را صدا میزند که `request.getUserInfo()` (یعنی `GET /oauth/userinfo`) را فراخوانی میکند و فقط در صورت `res.uuid` کوکی `userInfo` را ست و ریدایرکت میکند. اما پاسخ `oauth/userinfo` به شکل `{ success: true, data: { uuid, mobile_number, roles, ... } }` است و interceptor در `services/api.js` **یکبار** پاسخ را باز میکند، پس دادهی واقعی در `res.data` است، نه `res` مستقیم. در نتیجه `res.uuid === undefined`، شرط `if (res && res.uuid)` رد میشود، **نه کوکی `userInfo` ست میشود و نه ریدایرکت اتفاق میافتد** → صفحه میماند.
|
||||||
|
|
||||||
|
> توجه: این همان pitfall «double-nesting» است که قبلاً در پروفایل و لیست بیمه هم دیده شد. اینجا پاسخ یکلایه envelope دارد (`{success, data}`)؛ بعد از interceptor، داده در `res.data`.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
تابع `getInfo` در `SendReq.js` باید کاربر را از `res.data` بخواند (نه `res`)، کوکی `userInfo` را با **شیء کاربر** (نه envelope) ست کند، و سپس ریدایرکت/پیشروی مرحله را انجام دهد. همچنین در صورت پاسخ نامعتبر، خطای واضح نمایش داده شود (نه ماندن بیصدا).
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/register/verificationPage/SendReq.js` | بعد از گرفتن توکن، userinfo میگیرد و کوکی `userInfo` را ست میکند — محل باگ |
|
||||||
|
| `services/response.js` | `getUserInfo` → `GET /oauth/userinfo` با `requireAuth` |
|
||||||
|
| `services/api.js` | interceptor که یکبار `response.data` را برمیگرداند |
|
||||||
|
| `lib/auth.js` | `getUser()` — وجود کوکی `userInfo` را چک میکند (server-side) |
|
||||||
|
| مصرفکنندههای `userInfo` | `components/appointment/index.js`، `components/layout/sidebar/*`، `components/dashboard/*` — همگی `userInfo` را parse میکنند و انتظار شیء کاربر (`.uuid`، `.mobile_number`، `.representation_uuid`) دارند |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
### `SendReq.js` → `getInfo` (باگدار)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const getInfo = (token, cookieOptions) => {
|
||||||
|
request
|
||||||
|
.getUserInfo({ headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
.then((res) => {
|
||||||
|
setLoading(false);
|
||||||
|
if (res && res.uuid) { // ❌ uuid در res.data است نه res
|
||||||
|
Cookies.set("userInfo", JSON.stringify(res), cookieOptions); // ❌ کل envelope ذخیره میشود
|
||||||
|
if (setStep) {
|
||||||
|
setStep(3);
|
||||||
|
} else {
|
||||||
|
window.location.pathname = "/";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ❌ اگر uuid نبود، هیچ اتفاقی نمیافتد — صفحه میماند، خطا هم نشان داده نمیشود
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLoading(false);
|
||||||
|
setIsError(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### قرارداد واقعی `GET /oauth/userinfo` (از `clinicpro/docs/api/auth.md`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": 4766,
|
||||||
|
"uuid": "550e8400-...",
|
||||||
|
"mobile_number": "09123456789",
|
||||||
|
"realName": "...",
|
||||||
|
"roles": ["ROLE_USER"],
|
||||||
|
"primary_role": "...",
|
||||||
|
"doctor_uuid": "...",
|
||||||
|
"representation_uuid": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> بعد از interceptor: داده در `res.data`. هیچ فیلد `username` در پاسخ نیست؛ شماره موبایل `mobile_number` است.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. خواندن کاربر از `res.data` و ستکردن کوکی با شیء کاربر
|
||||||
|
|
||||||
|
```js
|
||||||
|
const getInfo = (token, cookieOptions) => {
|
||||||
|
request
|
||||||
|
.getUserInfo({ headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
.then((res) => {
|
||||||
|
setLoading(false);
|
||||||
|
const userInfo = res?.data;
|
||||||
|
if (userInfo?.uuid) {
|
||||||
|
Cookies.set("userInfo", JSON.stringify(userInfo), cookieOptions);
|
||||||
|
if (setStep) {
|
||||||
|
setStep(3);
|
||||||
|
} else {
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsError(true);
|
||||||
|
toast.error("دریافت اطلاعات کاربر ناموفق بود. دوباره تلاش کنید.");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLoading(false);
|
||||||
|
setIsError(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- کوکی `userInfo` باید **شیء کاربر** (`res.data`) باشد تا مصرفکنندهها (`parsedData.uuid`, `.mobile_number`, `.representation_uuid`) درست کار کنند.
|
||||||
|
- `window.location.href = "/"` بهجای `window.location.pathname = "/"` (مطمئنتر برای reload کامل و خواندن کوکیهای تازه server-side).
|
||||||
|
- `toast` از قبل در این فلو استفاده میشود (در همان فایل بعد از رفع قبلی `import { toast } from "react-toastify"` اضافه شده) — اگر نبود اضافه کن؛ `ToastContainer` در `app/layout.js` mount است.
|
||||||
|
|
||||||
|
### ۲. سازگاری مصرفکنندههای `username`
|
||||||
|
|
||||||
|
- بعضی جاها (مثل `components/appointment/index.js`) `parsedData.username` خوانده میشود، ولی پاسخ `oauth/userinfo` فیلد `username` ندارد (فقط `mobile_number`). بررسی کن:
|
||||||
|
- یا در `getInfo` هنگام ذخیره، یک فیلد `username` معادل `mobile_number` هم اضافه کن (سازگاری عقبرو، کمریسکتر):
|
||||||
|
```js
|
||||||
|
const userInfo = { ...res.data, username: res.data.mobile_number };
|
||||||
|
```
|
||||||
|
- یا همهی خوانندههای `username` را به `mobile_number` مهاجرت بده.
|
||||||
|
- **یکی را انتخاب کن**؛ گزینهی اول (افزودن `username`) دیف کوچکتری دارد و فلوی appointment را نمیشکند. در گزارش ذکر کن.
|
||||||
|
|
||||||
|
### ۳. تأیید عدم ماندن بیصدا
|
||||||
|
|
||||||
|
- اگر `verify-code` یا `oauth/token` خطا داد (۴۰۰)، از قبل (رفع قبلی) پیام toast نمایش داده میشود. مطمئن شو مسیر userinfo هم در خطا، `setIsError(true)` و پیام میدهد، نه سکوت.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **هیچ تغییری در backend لازم نیست.** پاسخ `oauth/userinfo` درست است؛ فقط استخراج سمت کلاینت غلط بود.
|
||||||
|
- **interceptor:** همهی `request.*` بدنهی HTTP را یکبار باز میکنند → برای `{success, data}` داده در `res.data`. این الگو را با pitfallهای قبلی (پروفایل، بیمه) یکدست نگهدار.
|
||||||
|
- **شکل کوکی `userInfo`:** باید شیء کاربر باشد (نه `{success, data}`)؛ مصرفکنندههای متعدد (`appointment`, `sidebar`, `dashboard`, `lib/auth.getUser`) به این متکیاند. تغییر این شکل را در همهی مصرفکنندهها بررسی کن.
|
||||||
|
- **دو مسیر استفاده از `SendReq`:** صفحهی مستقل `/login` (`RegisterPage` با `setStep={false}` → ریدایرکت به `/`) و فلوی نوبت (`setStep` تابع است → `setStep(3)`). هر دو باید بعد از رفع کار کنند. هر دو را تست کن.
|
||||||
|
- **dev OTP:** کد ثابت `12345` است؛ هنگام تست با همین وارد شو.
|
||||||
|
- **rate-limit:** backend روی `send-code` rate-limit دارد؛ اگر حین تست `ERR_RATE_LIMIT_001` گرفتی، چند دقیقه صبر کن یا شمارهی متفاوت بزن.
|
||||||
|
- **تست:** `npm run build`؛ سپس دستی روی `/login`: موبایل + `12345` → باید کوکیهای `access_token`/`refresh_token`/`uuid`/`userInfo` ست شوند و به `/` ریدایرکت شود. سپس commit با پیام توصیفی.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# رفع فیلتر وضعیت در «نوبتهای من» (نوبت ثبتشده نمایش داده نمیشود)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی، داشبورد کاربر).
|
||||||
|
|
||||||
|
> این باگ کاملاً frontend است. بکاند درست کار میکند: `GET /api/v1/appointments/user?status=<status>` نوبتهای کاربر فعلی را برمیگرداند و فیلتر `status` را روی مقادیر واقعی نوبت اعمال میکند.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
کاربر در nobat724 نوبت گرفته (وضعیت `confirmed`) ولی در «نوبتهای من» (تبهای تاریخچه نوبت) چیزی نمایش داده نمیشود. ریشهیابی نشان داد بکاند نوبت را درست برمیگرداند؛ مشکل این است که تبهای فیلتر در frontend مقادیر `status` نادرستی به API میفرستند که با وضعیتهای واقعی بکاند مطابقت ندارند، پس هر تب جز «همه» نتیجهی خالی میدهد.
|
||||||
|
|
||||||
|
تأیید با دادهی واقعی:
|
||||||
|
- `GET /api/v1/appointments/user?status=reserved` → **۰ نوبت** (مقدار `reserved` در بکاند وجود ندارد).
|
||||||
|
- `GET /api/v1/appointments/user?status=confirmed` → **۴ نوبت** (مقدار درست).
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
تبهای `Head.js` این مقادیر را میفرستند:
|
||||||
|
`waiting_for_payment`, `reserved`, `visited`, `auto_cancel_unpaid`, `completed`
|
||||||
|
|
||||||
|
اما وضعیتهای واقعی نوبت در بکاند (همانهایی که `List.js` در `STATUS_LABELS` میشناسد و `Appointment` entity تعریف میکند):
|
||||||
|
`pending`, `confirmed`, `completed`, `cancelled_by_user`, `cancelled_by_doctor`, `expired`, `no_show`
|
||||||
|
|
||||||
|
هدف: نگاشت تبها به مقادیر واقعی + هماهنگکردن برچسبها، تا فیلتر درست کار کند.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/dashboard/userAccount/sidebars/turns/Head.js` | تبهای فیلتر — `listTab` و `statusMap` نادرست |
|
||||||
|
| `components/dashboard/userAccount/sidebars/turns/List.js` | `STATUS_LABELS` (مرجع مقادیر واقعی — درست است) |
|
||||||
|
| `components/dashboard/userAccount/sidebars/turns/index.js` | `Turns` — `status` را به `getMyAppointments` پاس میدهد (درست است، تغییر لازم ندارد) |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
`Head.js` — نگاشت نادرست:
|
||||||
|
```jsx
|
||||||
|
const listTab = ["همه", "در انتظار پرداخت", "رزرو شده", "ویزیت شده", "لغو خودکار", "تکمیل شده"];
|
||||||
|
|
||||||
|
const statusMap = {
|
||||||
|
0: undefined, // all
|
||||||
|
1: "waiting_for_payment", // ❌ بکاند: pending
|
||||||
|
2: "reserved", // ❌ بکاند: confirmed
|
||||||
|
3: "visited", // ❌ بکاند: completed (یا no_show)
|
||||||
|
4: "auto_cancel_unpaid", // ❌ بکاند: expired
|
||||||
|
5: "completed", // فقط این یکی تصادفاً درست است
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`List.js` — مقادیر واقعی (مرجع درست):
|
||||||
|
```jsx
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
pending: "در انتظار پرداخت",
|
||||||
|
confirmed: "تأیید شده",
|
||||||
|
completed: "انجام شده",
|
||||||
|
cancelled_by_user: "لغو شده",
|
||||||
|
cancelled_by_doctor: "لغو توسط پزشک",
|
||||||
|
expired: "منقضی شده",
|
||||||
|
no_show: "عدم مراجعه",
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. اصلاح نگاشت تبها در `Head.js`
|
||||||
|
`listTab` و `statusMap` را با وضعیتهای واقعی بکاند هماهنگ کن. تبهای پیشنهادی (برچسب فارسی ← مقدار واقعی API):
|
||||||
|
```jsx
|
||||||
|
const listTab = ["همه", "در انتظار پرداخت", "تأیید شده", "انجام شده", "لغو شده", "منقضی شده"];
|
||||||
|
|
||||||
|
const statusMap = {
|
||||||
|
0: undefined, // همه
|
||||||
|
1: "pending",
|
||||||
|
2: "confirmed",
|
||||||
|
3: "completed",
|
||||||
|
4: "cancelled_by_user",
|
||||||
|
5: "expired",
|
||||||
|
};
|
||||||
|
```
|
||||||
|
- برچسبها باید با `STATUS_LABELS` در `List.js` یکدست باشند (مثلاً `confirmed` → «تأیید شده»).
|
||||||
|
- اگر میخواهی «لغو توسط پزشک» و «عدم مراجعه» هم فیلتر شوند، میتوانی تب اضافه کنی؛ ولی چون فیلتر بکاند فقط یک مقدار `status` میگیرد، هر تب باید دقیقاً یک مقدار معتبر بفرستد.
|
||||||
|
|
||||||
|
### ۲. تأیید جریان
|
||||||
|
- `index.js`: `if (status) params.status = status;` — وقتی `undefined` باشد (تب «همه») پارامتر ارسال نمیشود و همهی نوبتها میآیند. این درست است؛ تغییر نده.
|
||||||
|
- مطمئن شو تب پیشفرض «همه» (index 0 → `undefined`) است تا در اولین بارگذاری همهی نوبتها (از جمله `confirmed`) نمایش داده شوند.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **بکاند تغییر نمیکند.** مقادیر معتبر `status` دقیقاً ثابتهای `Appointment` هستند: `pending`, `confirmed`, `completed`, `cancelled_by_user`, `cancelled_by_doctor`, `expired`, `no_show`. هر مقدار دیگری → لیست خالی.
|
||||||
|
- پاسخ `appointments/user` دابلنِست است (`success(['data'=>...])`)؛ `index.js` درست با `response.data.data` میخواند — دست نزن.
|
||||||
|
- تاریخ/ساعت در `List.js` با `convertTimestampToJalali`/`convertTimestampToTime` از `slot_start` (Unix ثانیه) ساخته میشود — درست است.
|
||||||
|
- App Router، RTL، فونت Vazir، MUI v5؛ از کامپوننت `Tabs` موجود استفاده کن.
|
||||||
|
- بعد از تغییر: `npm run build` بدون خطا؛ در داشبورد همهی تبها را تست کن (بهویژه «تأیید شده» که باید نوبت confirmed را نشان دهد، و «همه» که همه را نشان دهد).
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# اصلاح جریان پرداخت: انتخاب فقط درگاههای فعالِ بکاند + بازگشت به دامنه مبدأ
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (frontend) + `clinicpro` (backend) — **cross-repo**.
|
||||||
|
|
||||||
|
> **بهروزرسانی (جریان انتقال به بانک از طریق بکاند):** بهجای اینکه frontend مستقیم `redirect_url` بانک را باز کند، مرورگر باید به یک endpoint بکاند برود و بکاند انتقال به درگاه را انجام دهد. این در بکاند با `GET /api/v1/payment/pay/{orderId}` پیاده شد (302 برای درگاه GET مثل سپ، فرم auto-submit با POST برای ملت — چون startpay ملت با POST باز میشود). `POST /api/v1/payment/appointment` حالا علاوه بر `redirect_url` یک `pay_url` هم برمیگرداند؛ frontend باید `pay_url` را باز کند. جریان کامل: کلیک → XHR authenticated (تأیید صلاحیت اوردر + init درگاه) → مرورگر به `pay_url` (بکاند) → بکاند به بانک → پرداخت → callback بکاند (verify) → 302 به `frontend_address` (دامنه مبدأ).
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
جریان پرداخت نوبت بهصورت زیر است و **سمت بکاند کامل پیادهسازی شده**:
|
||||||
|
|
||||||
|
1. کاربر روی «پرداخت» میزند → frontend با `POST /api/v1/payment/appointment` (همراه JWT) و بدنهٔ `{ appointment_uuid, gateway, frontend_address }` درخواست میدهد.
|
||||||
|
2. بکاند **صلاحیت اوردر** را چک میکند (مالکیت کاربر، وضعیت نوبت `pending|confirmed`)، `frontend_address` را در برابر هاستهای مجاز اعتبارسنجی میکند، **درگاهِ فعال** را resolve میکند (`resolveGateway` → `isGatewayEnabled`)، به درگاه request میزند و `redirect_url` را برمیگرداند.
|
||||||
|
3. frontend کاربر را با `window.location.href = redirect_url` به درگاه میفرستد.
|
||||||
|
4. بعد از پرداخت، درگاه به `GET/POST /api/v1/payment/callback/{gateway}` (عمومی، محدود به IP شاپرک) برمیگردد؛ بکاند **صلاحیت پرداخت** را verify میکند (مبلغ، replay، مرجع)، وضعیت را ست میکند، نوبت را confirm میکند و در پایان با `redirectToFrontend` یک **302 به `frontend_address`** میزند: `frontend_address?payment_uuid=...&status=...`.
|
||||||
|
5. چون `frontend_address` همان دامنهٔ مبدأ کاربر است، بازگشت به **همان دامنهای که از اول آمده بود** انجام میشود.
|
||||||
|
|
||||||
|
پس منطق «ریدایرکت به بکاند → تأیید اوردر → درگاه → بازگشت به بکاند → تأیید پرداخت → بازگشت به دامنه مبدأ» از قبل کار میکند. مشکل فعلی سمت frontend است.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
دو ایراد در `components/appointment/paying/index.js`:
|
||||||
|
|
||||||
|
1. **لیست درگاهها هاردکد شده** و به «درگاههای فعالِ بکاند» توجه نمیکند. آرایهٔ ثابت `banks` (mellat, sep) در Select نمایش داده میشود، در حالیکه `GET /api/v1/payment/config` فیلد `gateways` را برمیگرداند که فقط شامل درگاههای **پیکربندیشده و فعال** است (`activeGateways` در بکاند). اگر ادمین یک درگاه را در بکاند غیرفعال کند، همچنان در frontend نمایش داده میشود و انتخاب آن باعث خطای `422 درگاه پرداخت نامعتبر یا غیرفعال است` میشود.
|
||||||
|
2. **`gateways` از config نادیده گرفته میشود**؛ `getPaymentConfig` فقط `test_mode` و `appointment_fee_rials` را میخواند.
|
||||||
|
|
||||||
|
هدف: Select درگاه فقط از `config.gateways` پر شود، درگاه پیشفرض اولین درگاهِ فعال باشد، و اگر هیچ درگاهی فعال نبود دکمهٔ پرداخت غیرفعال شود.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `nobat724_front/components/appointment/paying/index.js` | کامپوننت پرداخت — دکمه، Select درگاه، فراخوانی API |
|
||||||
|
| `nobat724_front/services/response.js` | `getPaymentConfig`، `postAppointmentPayment` (بدون تغییر) |
|
||||||
|
| `nobat724_front/app/payment/result/page.js` | صفحهٔ بازگشت از بکاند؛ `payment_uuid` را میخواند و به `/payment/{uuid}` میرود (بدون تغییر) |
|
||||||
|
|
||||||
|
قرارداد `GET /api/v1/payment/config` (از بکاند، بدون تغییر):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"test_mode": false,
|
||||||
|
"appointment_fee_rials": 250000,
|
||||||
|
"gateways": [ { "name": "mellat", "label": "بانک ملت" }, { "name": "sep", "label": "سپ (سامان کیش)" } ]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
در حالت `test_mode: true` بکاند فقط `[{ "name": "mellat", "label": "بانک ملت (آزمایشی)" }]` برمیگرداند و درگاه واقعی resolve نمیشود (`MockGateway`).
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
```js
|
||||||
|
// components/appointment/paying/index.js
|
||||||
|
const banks = [
|
||||||
|
{ id: "mellat", title: "بانک ملت" },
|
||||||
|
{ id: "sep", title: "سامان (سپ)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Paying({ ... }) {
|
||||||
|
const [selectedBank, setSelectedBank] = useState("mellat");
|
||||||
|
const [testMode, setTestMode] = useState(null);
|
||||||
|
const [feeRials, setFeeRials] = useState(null);
|
||||||
|
// ...
|
||||||
|
useEffect(() => {
|
||||||
|
request.getPaymentConfig()
|
||||||
|
.then((res) => {
|
||||||
|
setTestMode(Boolean(res?.data?.test_mode));
|
||||||
|
setFeeRials(Number(res?.data?.appointment_fee_rials) || 0);
|
||||||
|
// ❌ res.data.gateways نادیده گرفته میشود
|
||||||
|
})
|
||||||
|
.catch(() => { setTestMode(false); setFeeRials(0); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePayment = async () => {
|
||||||
|
// ...
|
||||||
|
const res = await request.postAppointmentPayment({
|
||||||
|
appointment_uuid: appointmentId,
|
||||||
|
gateway: testMode ? "mellat" : selectedBank, // ❌ selectedBank از لیست هاردکد
|
||||||
|
frontend_address: `${window.location.origin}/payment/result`,
|
||||||
|
});
|
||||||
|
const redirectUrl = res?.data?.redirect_url;
|
||||||
|
if (redirectUrl) window.location.href = redirectUrl;
|
||||||
|
else setStep((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
// ...
|
||||||
|
{/* Select درگاه از آرایهٔ ثابت banks پر میشود */}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. خواندن `gateways` از config و نگهداری در state
|
||||||
|
|
||||||
|
آرایهٔ ثابت `banks` را حذف کن و بهجای آن از `config.gateways` استفاده کن:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const [gateways, setGateways] = useState([]); // [{ name, label }]
|
||||||
|
const [selectedBank, setSelectedBank] = useState(""); // خالی تا config برسد
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request.getPaymentConfig()
|
||||||
|
.then((res) => {
|
||||||
|
const gws = Array.isArray(res?.data?.gateways) ? res.data.gateways : [];
|
||||||
|
setTestMode(Boolean(res?.data?.test_mode));
|
||||||
|
setFeeRials(Number(res?.data?.appointment_fee_rials) || 0);
|
||||||
|
setGateways(gws);
|
||||||
|
setSelectedBank(gws[0]?.name ?? ""); // پیشفرض = اولین درگاه فعال
|
||||||
|
})
|
||||||
|
.catch(() => { setTestMode(false); setFeeRials(0); setGateways([]); });
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. پر کردن Select از `gateways` فعال
|
||||||
|
|
||||||
|
در JSX، `banks.map` را با `gateways.map` جایگزین کن (کلید/مقدار = `name`، متن = `label`):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Select
|
||||||
|
labelId="bank-select-label"
|
||||||
|
value={selectedBank}
|
||||||
|
label="انتخاب درگاه پرداخت"
|
||||||
|
onChange={(e) => setSelectedBank(e.target.value)}
|
||||||
|
>
|
||||||
|
{gateways.map((g) => (
|
||||||
|
<MenuItem key={g.name} value={g.name}>{g.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
اگر `!testMode && gateways.length === 0` بود، بهجای Select یک پیام «درگاه پرداخت فعالی موجود نیست» نشان بده و دکمهٔ پرداخت را غیرفعال کن.
|
||||||
|
|
||||||
|
### ۳. ارسال درگاه انتخابشده و غیرفعالسازی دکمه در نبود درگاه
|
||||||
|
|
||||||
|
در `handlePayment`، gateway را از `selectedBank` بفرست (در test_mode بکاند بههرحال Mock را resolve میکند، ولی مقدار `mellat` سازگار است):
|
||||||
|
|
||||||
|
```js
|
||||||
|
gateway: testMode ? "mellat" : selectedBank,
|
||||||
|
```
|
||||||
|
|
||||||
|
قبل از ارسال، اگر `!testMode && !selectedBank` بود return کن. شرط `disabled` دکمهٔ پرداخت را گسترش بده:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
disabled={
|
||||||
|
loading || testMode === null || feeRials === null ||
|
||||||
|
(!testMode && !selectedBank)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`frontend_address: `${window.location.origin}/payment/result`` را **بدون تغییر** نگهدار — همین تضمین میکند بازگشت به همان دامنهٔ مبدأ (multi-domain) انجام شود.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **بازگشت به دامنه مبدأ از قبل درست است:** چون `frontend_address` از `window.location.origin` ساخته میشود، بکاند در callback به همان دامنهای که کاربر از آن آمده 302 میزند. این را تغییر نده.
|
||||||
|
- **بررسی تنظیمات بکاند (بدون تغییر کد):** بکاند در `initiateAppointment` مقدار `frontend_address` را با `isAllowedFrontend` در برابر `payment_allowed_frontend_hosts` (SiteConfig، fallback به env) چک میکند؛ اگر لیست خالی باشد یا هاست دامنهٔ شهر در آن نباشد، پاسخ `422 آدرس بازگشت مجاز نیست` است و پرداخت شروع نمیشود. برای پشتیبانی از همهٔ دامنههای چند-شهری، مطمئن شو **هاست همهٔ دامنهها** (بدون `https://`، فقط host مثل `yazd-nobat.ir`) در این تنظیم موجود است. این تغییر داده/کانفیگ است، نه کد.
|
||||||
|
- درگاهها را در frontend هاردکد نکن؛ منبع واحدِ حقیقت `GET /api/v1/payment/config` → `gateways` است.
|
||||||
|
- الگوی فراخوانی API همان `services/response.js` → `request.*` با `{ requireAuth: true }` است؛ متد جدیدی لازم نیست.
|
||||||
|
- بعد از تغییر: `npm run build` در `nobat724_front` تا خطای صفحه/کامپوننت گرفته شود. تست دستی: انتخاب درگاه فعال، رفتن به درگاه، بازگشت به `/payment/result` روی همان دامنه، و نمایش نتیجه در `/payment/{uuid}`.
|
||||||
|
- edge case: اگر `test_mode` روشن است، Select نمایش داده نمیشود (کارت «درگاه آزمایشی») و باید مثل الان کار کند؛ فقط مطمئن شو منطق جدیدِ `gateways` جریان test_mode را نمیشکند (در test_mode هم `gateways` یک آیتم دارد ولی UI آن را نشان نمیدهد).
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# رفع خطای `TypeError: fetch failed` در sitemap هنگام دیپلوی (Docker build)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
هنگام دیپلوی روی سرور (Coolify، Docker multi-stage build) در لاگ build این خطا ظاهر میشود:
|
||||||
|
|
||||||
|
```
|
||||||
|
#17 192.1 Error fetching sitemap data from /api/v1/clinics: TypeError: fetch failed
|
||||||
|
```
|
||||||
|
|
||||||
|
`#17` همان مرحلهی builder در `Dockerfile` است (`RUN npm run build`، خط ۵۵). یعنی این خطا در **زمان build** رخ میدهد، نه زمان اجرا.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
`app/sitemap.js` یک metadata route است. Next.js هنگام `next build` تلاش میکند این route را **prerender** کند و در همان لحظه تابع `sitemap()` اجرا میشود؛ این تابع از طریق `fetchAllPages()` به `NEXT_PUBLIC_API_URL` (یعنی `api.clinic-pro.ir`) درخواست `fetch` میزند.
|
||||||
|
|
||||||
|
کانتینر build در Coolify به این API دسترسی شبکهای ندارد (شبکه build ایزوله است / DNS در دسترس نیست) → `fetch` با `TypeError: fetch failed` شکست میخورد.
|
||||||
|
|
||||||
|
خطا داخل `try/catch` تابع `fetchAllPages` گرفته میشود (خط ۷۸–۸۰)، پس build **کرش نمیکند** ولی نتیجهاش این است که sitemap تولیدشده **خالی** است (فقط صفحات استاتیک، بدون هیچ URL پزشک/کلینیک/بلاگ). این هم لاگ خطای آزاردهنده میدهد و هم SEO را خراب میکند.
|
||||||
|
|
||||||
|
**هدف:** sitemap در زمان build اصلاً به API وصل نشود؛ دادهها در زمان **اجرا (request-time)** روی سرور production گرفته شوند — جایی که کانتینر runtime به API دسترسی دارد.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `app/sitemap.js` | تولید sitemap؛ محل fetch در زمان build |
|
||||||
|
| `Dockerfile` | مرحلهی builder خط ۵۵ (`RUN npm run build`) جایی که خطا رخ میدهد — فقط برای درک، تغییر نمیکند |
|
||||||
|
| `app/robots.js` | مشابه sitemap؛ ولی fetch ندارد — احتمالاً نیاز به تغییر نیست، فقط چک شود |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
`app/sitemap.js` هیچ `export const dynamic` یا `revalidate` ندارد، پس Next آن را کاندید static prerender در build میداند. تابع fetch:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// app/sitemap.js — خط 56
|
||||||
|
async function fetchAllPages(path, extraParams = {}) {
|
||||||
|
if (!API_URL) return [];
|
||||||
|
const results = [];
|
||||||
|
try {
|
||||||
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||||
|
const search = new URLSearchParams({
|
||||||
|
...extraParams,
|
||||||
|
page: String(page),
|
||||||
|
limit: String(PAGE_LIMIT),
|
||||||
|
});
|
||||||
|
const res = await fetch(`${API_URL}${path}?${search.toString()}`, {
|
||||||
|
next: { revalidate: 3600 },
|
||||||
|
});
|
||||||
|
if (!res.ok) break;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching sitemap data from ${path}:`, error);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// خط 140
|
||||||
|
export default async function sitemap() { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. اجبار sitemap به رندر در زمان اجرا (نه build)
|
||||||
|
|
||||||
|
بالای `app/sitemap.js` (بعد از importها) این دو خط را اضافه کن تا Next هرگز sitemap را در build فچ نکند و همیشه per-request روی سرور production تولید شود:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const revalidate = 3600; // کش ۱ ساعته در لایهی سرور
|
||||||
|
```
|
||||||
|
|
||||||
|
> نکته: تابع فعلاً از `await headers()` استفاده میکند که باید route را dynamic کند، اما لاگ build ثابت میکند که همچنان در build اجرا میشود. `force-dynamic` این را قطعی میکند. بعد از افزودن آن، بلوک `next: { revalidate: 3600 }` داخل `fetch` را نگهدار — با `force-dynamic` هم بیضرر است.
|
||||||
|
|
||||||
|
### ۲. مقاومسازی fetch با timeout
|
||||||
|
|
||||||
|
اگر در زمان اجرا API کند یا موقتاً down باشد، هر صفحهی sitemap نباید بینهایت منتظر بماند. به `fetch` یک timeout اضافه کن:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const res = await fetch(`${API_URL}${path}?${search.toString()}`, {
|
||||||
|
next: { revalidate: 3600 },
|
||||||
|
signal: AbortSignal.timeout(8000), // 8s برای هر صفحه
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`try/catch` موجود (خط ۷۸) خطای timeout را هم میگیرد، پس رفتار fail-safe فعلی (بازگشت آرایهی جمعشده تا آن لحظه) حفظ میشود.
|
||||||
|
|
||||||
|
### ۳. (اختیاری) لاگ تمیزتر برای شبکهی در دسترسنبودن
|
||||||
|
|
||||||
|
پیام خطای فعلی خام است. برای اینکه در آینده گیجکننده نباشد، پیام را کمی روشنتر کن (فقط پیام، منطق دستنخورده):
|
||||||
|
|
||||||
|
```js
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[sitemap] failed to fetch ${path} (page skipped):`, error?.message || error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **ریشهی واقعی شبکه build**: `NEXT_PUBLIC_API_URL` در `Dockerfile` بهعنوان build arg پاس داده میشود چون برای inline شدن در bundle کلاینت لازم است — این درست است و نباید حذف شود. مشکل صرفاً این است که *در حین build نباید به آن fetch زده شود*. راهحل بالا همین را حل میکند؛ **نیازی به تغییر Dockerfile نیست**.
|
||||||
|
- بعد از این تغییر، sitemap فقط زمانی که خزنده یا کاربر `/sitemap.xml` را روی سرور production باز کند تولید میشود؛ آنجا کانتینر runtime به `api.clinic-pro.ir` دسترسی دارد.
|
||||||
|
- معماری multi-domain حفظ شود: `sitemap()` از `await headers()` برای تشخیص host/شهر استفاده میکند — با `force-dynamic` این هدرها در زمان اجرا در دسترساند (در build نبودند). این یک دلیل اضافه برای درست بودن `force-dynamic` است.
|
||||||
|
- `output: 'standalone'` در `next.config.js` فعال است؛ route داینامیک در سرور standalone بدون مشکل کار میکند.
|
||||||
|
- تست محلی: `npm run build` باید بدون خطای `fetch failed` تمام شود؛ سپس `npm run start` و باز کردن `http://yazd-nobat.localhost:3000/sitemap.xml` باید URLهای پزشک/کلینیک را نشان دهد (با API در دسترس).
|
||||||
|
- `app/robots.js` را چک کن که fetch نداشته باشد؛ اگر ندارد دستنخورده بماند.
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# سختسازی امنیتی سایت عمومی: ذخیرهی توکن، XSS، هدرها، client_secret
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی). این پرامپت **cross-repo** است — پرامپت همتای backend: `clinicpro/.claude/prompt/fix-auth-token-hardening.md` که **اول** باید اجرا شود (قرارداد توکن را عوض میکند: `oauth/token` بهجای `uuid` فیلد `grant` میگیرد و `verify-code` فیلد `grant` برمیگرداند).
|
||||||
|
|
||||||
|
مرجع: گزارش امنیتی این session (OWASP Top 10). یافتههای frontend: **C-2 (Critical)**، **C-3 (Critical)**، **H-2 (High)**، **H-3 (High)**.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
ممیزی امنیتی این مشکلات را در سایت عمومی پیدا کرد:
|
||||||
|
|
||||||
|
- **C-2:** `access_token` و `refresh_token` با `js-cookie` ست میشوند (`Cookies.set(...)`) — یعنی **غیر HttpOnly، بدون Secure، بدون SameSite**؛ هر اسکریپتی میتواند بخواندشان. با XSS → سرقت کامل توکن.
|
||||||
|
- **C-3:** بدنهی بلاگ/کلینیک/پزشک با `dangerouslySetInnerHTML={{ __html: sanitizeHtml(...) }}` رندر میشود، اما `lib/sanitize.js` یک sanitizer **regex دستی و قابل دور زدن** است (مثلاً `<img src=x onerror=...>` چون `<img>` در لیست خطرناک نیست عبور میکند). → Stored XSS.
|
||||||
|
- **H-2:** `next.config.js` هیچ تابع `headers()` ندارد → بدون CSP/HSTS/X-Frame-Options/nosniff/Referrer-Policy/Permissions-Policy.
|
||||||
|
- **H-3:** توکن با `NEXT_PUBLIC_CLIENT_SECRET` گرفته میشود؛ هر مقدار `NEXT_PUBLIC_*` داخل bundle مرورگر inline و عمومی میشود.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
refresh token را به کوکی **HttpOnly سمت سرور** ببر و توکنگیری/OAuth را در یک Route Handler سرور-ساید انجام بده (تا `client_secret` و `refresh_token` هرگز به مرورگر نروند)؛ sanitizer را با **DOMPurify** عوض کن؛ security headers را در `next.config.js` اضافه کن.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `services/api.js` | interceptor — خواندن `access_token` از کوکی |
|
||||||
|
| `services/response.js` | `getToken` که `client_secret` میفرستد |
|
||||||
|
| `components/register/verificationPage/SendReq.js` | ستکردن کوکیها بعد از لاگین (`Cookies.set`) |
|
||||||
|
| `components/appointment/detail/SubmitData.js` | `Cookies.set("access_token"/"refresh_token")` |
|
||||||
|
| `components/dashboard/userAccount/.../ButtonSendData.js` | `Cookies.set("access_token")` |
|
||||||
|
| `lib/sanitize.js` | sanitizer regex فعلی |
|
||||||
|
| `lib/req.js` / `services/clinicApi.js` | فراخوانیهای مصرفکنندهی توکن جدید |
|
||||||
|
| `app/api/auth/token/route.js` (جدید) | Route Handler سرور-ساید برای OAuth + ست کوکی HttpOnly |
|
||||||
|
| `next.config.js` | افزودن `headers()` |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
`services/response.js` — `client_secret` در درخواست:
|
||||||
|
```js
|
||||||
|
getToken: (grant_type, client_id, client_secret, uuid, code, scope = "nobat724") => {
|
||||||
|
// ...
|
||||||
|
formData.append("client_secret", client_secret);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`components/register/verificationPage/SendReq.js` (و مشابه در SubmitData.js):
|
||||||
|
```js
|
||||||
|
Cookies.set("access_token", response.access_token, cookieOptions);
|
||||||
|
Cookies.set("refresh_token", response.refresh_token, { expires: 7 });
|
||||||
|
```
|
||||||
|
|
||||||
|
`services/api.js` interceptor:
|
||||||
|
```js
|
||||||
|
if (config.requireAuth) {
|
||||||
|
const token = Cookies.get("access_token");
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`lib/sanitize.js` — regex قابل دور زدن:
|
||||||
|
```js
|
||||||
|
const DANGEROUS_TAGS = ['script','iframe','object','embed','link','meta','base','form'];
|
||||||
|
export function sanitizeHtml(html) {
|
||||||
|
let sanitized = html;
|
||||||
|
DANGEROUS_TAGS.forEach((tag) => { /* regex replace */ });
|
||||||
|
sanitized = sanitized.replace(/\s+on\w+\s*=\s*.../gi, '');
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`next.config.js` — بدون `headers()`.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. OAuth + ست کوکی در یک Route Handler سرور-ساید (C-2, H-3)
|
||||||
|
|
||||||
|
یک Route Handler بساز: `app/api/auth/token/route.js`. این هندلر:
|
||||||
|
- ورودی `{ grant }` (مطابق قرارداد جدید backend) را از body میگیرد.
|
||||||
|
- توکن را با `process.env.CLIENT_SECRET` (**بدون** `NEXT_PUBLIC`) و `process.env.CLIENT_ID` از backend (`oauth/token`) میگیرد.
|
||||||
|
- `refresh_token` را در کوکی **HttpOnly; Secure; SameSite=Lax; path=/** ست میکند.
|
||||||
|
- `access_token` را در پاسخ JSON برمیگرداند تا کلاینت در **memory** نگهش دارد (نه کوکی، نه localStorage).
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export async function POST(req) {
|
||||||
|
const { grant } = await req.json();
|
||||||
|
const res = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/oauth/token`, {
|
||||||
|
grant_type: 'mobile',
|
||||||
|
grant,
|
||||||
|
client_id: process.env.CLIENT_ID,
|
||||||
|
client_secret: process.env.CLIENT_SECRET,
|
||||||
|
});
|
||||||
|
const { access_token, refresh_token, expires_in } = res.data;
|
||||||
|
const jar = cookies();
|
||||||
|
jar.set('refresh_token', refresh_token, {
|
||||||
|
httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30,
|
||||||
|
});
|
||||||
|
return Response.json({ access_token, expires_in });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
یک Route Handler refresh هم بساز (`app/api/auth/refresh/route.js`) که `refresh_token` را از کوکی HttpOnly میخواند، `oauth/token/refresh` را صدا میزند، کوکی جدید ست میکند و access token تازه برمیگرداند. و `app/api/auth/logout/route.js` که کوکی را پاک و `oauth/logout` را صدا میزند.
|
||||||
|
|
||||||
|
> `services/response.js::getToken` که `client_secret` سمت کلاینت میفرستد را **حذف/جایگزین** کن با فراخوانی این Route Handler. هیچ `NEXT_PUBLIC_CLIENT_SECRET` در کد کلاینت نماند.
|
||||||
|
|
||||||
|
### ۲. مدیریت access token در memory بهجای کوکی JS (C-2)
|
||||||
|
|
||||||
|
- همهی `Cookies.set("access_token", ...)` و `Cookies.set("refresh_token", ...)` را حذف کن (SendReq.js, SubmitData.js, ButtonSendData.js و هرجای دیگر).
|
||||||
|
- access token را در یک ماژول in-memory نگهدار (مثلاً `lib/tokenStore.js` با یک متغیر و getter/setter، یا context). در `services/api.js` interceptor بهجای `Cookies.get("access_token")` از این store بخوان.
|
||||||
|
- روی 401، interceptor اول `app/api/auth/refresh` را امتحان کند؛ اگر شکست خورد، logout و redirect.
|
||||||
|
- چون access token در memory با refresh صفحه پاک میشود، در bootstrap اپ (مثلاً یک Provider بالای درخت) یکبار `app/api/auth/refresh` صدا بزن تا از روی کوکی HttpOnly، access token تازه بگیری.
|
||||||
|
|
||||||
|
> کوکیهای `uuid`/`userInfo` که حساس نیستند میتوانند بمانند ولی `userInfo` را به فیلدهای غیرحساس محدود کن.
|
||||||
|
|
||||||
|
### ۳. جایگزینی sanitizer با DOMPurify (C-3)
|
||||||
|
|
||||||
|
`isomorphic-dompurify` را نصب و `lib/sanitize.js` را بازنویسی کن (امضای `sanitizeHtml` حفظ شود تا `Caption.js`/`TextDetail.js`/`blog`/`clinic`/`doctor` تغییر نکنند):
|
||||||
|
```js
|
||||||
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
|
|
||||||
|
export function sanitizeHtml(html) {
|
||||||
|
if (!html || typeof html !== 'string') return '';
|
||||||
|
return DOMPurify.sanitize(html, {
|
||||||
|
ALLOWED_TAGS: ['p','br','strong','em','b','i','u','ul','ol','li','a','h2','h3','h4','blockquote','img','span','table','thead','tbody','tr','td','th'],
|
||||||
|
ALLOWED_ATTR: ['href','target','rel','src','alt','title'],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// safeJsonParse را همانطور که هست نگه دار
|
||||||
|
```
|
||||||
|
> `isomorphic-dompurify` در SSR (Server Component) و کلاینت هر دو کار میکند — مهم، چون این صفحات SSR هستند.
|
||||||
|
|
||||||
|
### ۴. Security headers در next.config.js (H-2)
|
||||||
|
|
||||||
|
تابع `headers()` به `nextConfig` اضافه کن:
|
||||||
|
```js
|
||||||
|
async headers() {
|
||||||
|
return [{
|
||||||
|
source: '/(.*)',
|
||||||
|
headers: [
|
||||||
|
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
|
||||||
|
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||||
|
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||||
|
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||||
|
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||||
|
{ key: 'Content-Security-Policy', value: [
|
||||||
|
"default-src 'self'",
|
||||||
|
"img-src 'self' https: data:",
|
||||||
|
"script-src 'self' 'unsafe-inline'", // اگر JSON-LD/Next نیاز داشت؛ در صورت امکان nonce
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"connect-src 'self' https://api.clinic-pro.ir",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
].join('; ') },
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
},
|
||||||
|
```
|
||||||
|
> CSP را با build و مرور صفحات اصلی (home/doctor/clinic/blog/panel) تست کن؛ اگر چیزی بلاک شد (MUI inline style, JSON-LD)، با `'unsafe-inline'` فقط برای style یا nonce برای script حلش کن — نه باز کردن کامل.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **ترتیب:** اول پرامپت backend اجرا شود (قرارداد `grant`)، بعد این. تا قبل از آن، `oauth/token` همچنان `uuid` میخواهد؛ این پرامپت بر اساس قرارداد جدید (`grant`) نوشته شده.
|
||||||
|
- App Router؛ Route Handlerها سرور-ساید هستند و به `process.env.CLIENT_SECRET` دسترسی دارند بدون افشا به مرورگر.
|
||||||
|
- multi-domain را نشکن: `domain` کوکی را مثل کد فعلی بر اساس hostname ست کن (برای کوکی HttpOnly در Route Handler هم همان منطق domain را اعمال کن تا روی سابدامینهای شهرها کار کند).
|
||||||
|
- بعد از تغییر env، مطمئن شو `CLIENT_SECRET` و `CLIENT_ID` (بدون `NEXT_PUBLIC`) در محیط deploy ست شدهاند (در `docker-compose.yml` از قبل `CLIENT_SECRET`/`CLIENT_ID` تعریف شده).
|
||||||
|
- تست:
|
||||||
|
- `npm run build` بدون خطا.
|
||||||
|
- جریان لاگین کامل: OTP → access token در memory، refresh در کوکی HttpOnly (در DevTools → Application → Cookies باید `HttpOnly` ✓ و `Secure` ✓ باشد).
|
||||||
|
- refresh صفحه → اپ از کوکی HttpOnly دوباره access token میگیرد و کاربر لاگین میماند.
|
||||||
|
- در DevTools Console: `document.cookie` نباید `access_token`/`refresh_token` نشان دهد.
|
||||||
|
- بدنهی بلاگ با payload تست `<img src=x onerror=alert(1)>` رندر شود ولی اجرا نشود (DOMPurify پاکش کند).
|
||||||
|
- هدرهای امنیتی در Network tab روی پاسخ صفحات دیده شوند.
|
||||||
|
- در bundle مرورگر (`.next/static`) رشتهی `client_secret` یا مقدار آن نباشد.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# سایت دامنه اختصاصی نماینده سراسری — تشخیص دامنه، فیلتر پزشکان/کلینیکها
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — **پیشنیاز:** پرامپت backend اول اجرا شود: `clinicpro/.claude/prompt/representation-multi-city-domain-commission.md`. این پرامپت مصرفکننده قراردادهای آن است:
|
||||||
|
- `GET /api/v1/site-context?domain=<host>` (عمومی) → `{ type: "city"|"representation"|"unknown", representation: {uuid, full_name, is_global}|null, city: {...}|null }`
|
||||||
|
- پارامتر جدید `domain` روی `GET /api/v1/doctors` و `GET /api/v1/clinics` — اگر دامنه متعلق به نماینده سراسری باشد، backend فقط پزشکان/کلینیکهای همان نماینده را برمیگرداند.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
سایت multi-domain است و دامنه فقط با `data/city.json` تطبیق داده میشود (`lib/getStateInfo.js`). نماینده سراسری دامنه اختصاصی خودش را دارد (مثل `x-nobat.ir`) که در city.json نیست → الان چنین دامنهای مثل «بدون شهر» رفتار میکند و همه پزشکان را نشان میدهد. باید: دامنه نماینده سراسری تشخیص داده شود و فقط پزشکان/کلینیکهای ثبتشده توسط همان نماینده نمایش یابند. کمیسیون خودش backend-side است (از `frontend_address` پرداخت) — فرانت فقط باید مثل الان دامنه درست را در `frontend_address` بفرستد (بدون تغییر).
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
۱. `getStateInfo` برای دامنههای خارج از city.json از API زمینه بگیرد (`site-context`) و `repContext` برگرداند.
|
||||||
|
۲. صفحات لیست پزشکان/کلینیکها روی دامنه نماینده سراسری، پارامتر `domain` را به API پاس بدهند.
|
||||||
|
۳. متادیتا/برندینگ صفحات روی دامنه نماینده از `full_name` نماینده ساخته شود (fallback «نوبت 724»).
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `lib/getStateInfo.js` | تشخیص دامنه — فقط city.json؛ باید repContext هم بدهد |
|
||||||
|
| `app/doctors/page.js` | لیست پزشکان — fetch `/api/v1/doctors` |
|
||||||
|
| `app/clinics/page.js` | لیست کلینیکها — fetch `/api/v1/clinics` |
|
||||||
|
| `app/layout.js` | متادیتای پایه از matchedCity |
|
||||||
|
| `components/home/*` (سرچ صفحه اصلی) | روی دامنه rep هم باید `domain` را پاس بدهد |
|
||||||
|
| `lib/req.js` | `fetchReq` برای server-side |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
`lib/getStateInfo.js` (کامل — کپی واقعی):
|
||||||
|
|
||||||
|
```js
|
||||||
|
export async function getStateInfo() {
|
||||||
|
const headersList = await headers();
|
||||||
|
const host = headersList.get("host") || "";
|
||||||
|
const subdomain = host.split(".")[0];
|
||||||
|
const matchedCity = citiesData.find((city) => {
|
||||||
|
const cityDomain = city.domain.split(".")[0];
|
||||||
|
return cityDomain === subdomain;
|
||||||
|
});
|
||||||
|
const matchedState =
|
||||||
|
matchedCity && statesData.find((state) => state.id === matchedCity.province_id);
|
||||||
|
return { matchedCity, matchedState, isRoot: isRootCity(matchedCity) };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`app/doctors/page.js` (بخش fetch — کپی واقعی):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { matchedCity, matchedState, isRoot } = await getStateInfo();
|
||||||
|
// ...
|
||||||
|
if (cityParams) newSearchParams.city = cityParams;
|
||||||
|
else if (matchedCity && !isRoot) newSearchParams.city = matchedCity.name;
|
||||||
|
const params = buildDoctorParams(newSearchParams);
|
||||||
|
doctors = await fetchReq(`${API_URL}/api/v1/doctors`, { params });
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. توسعه `getStateInfo` — repContext
|
||||||
|
|
||||||
|
خروجی جدید: `{ matchedCity, matchedState, isRoot, repContext }` که `repContext = { uuid, full_name, is_global } | null`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
export async function getStateInfo() {
|
||||||
|
// ... منطق فعلی city.json دستنخورده ...
|
||||||
|
let repContext = null;
|
||||||
|
if (!matchedCity && host) {
|
||||||
|
repContext = await fetchSiteContext(host); // فقط وقتی city match نشد
|
||||||
|
}
|
||||||
|
return { matchedCity, matchedState, isRoot: isRootCity(matchedCity), repContext };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `fetchSiteContext(host)`: صدا زدن `GET ${NEXT_PUBLIC_API_URL}/api/v1/site-context?domain=${host}` با `fetchReq`؛ اگر `type === "representation"` → آبجکت representation، وگرنه null. **خطای شبکه هرگز صفحه را نشکند** (try/catch → null) و پاسخ برای هر host **cache شود** (in-memory `Map` در سطح ماژول + `next: { revalidate: 300 }` اگر با fetch native؛ با axios همان Map با TTL ۵ دقیقه کافی است) — این تابع در هر render صدا میخورد.
|
||||||
|
- `localhost` و host خالی → بدون درخواست، null.
|
||||||
|
- تمام call-siteهای فعلی `getStateInfo` بدون تغییر کار کنند (فیلد اضافه فقط additive است).
|
||||||
|
|
||||||
|
### ۲. پاس دادن `domain` در لیستها
|
||||||
|
|
||||||
|
در `app/doctors/page.js` و `app/clinics/page.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { matchedCity, matchedState, isRoot, repContext } = await getStateInfo();
|
||||||
|
// ...
|
||||||
|
if (repContext?.is_global) {
|
||||||
|
params.domain = host; // host از headers — از طریق getStateInfo برگردان یا headers() مستقیم؟
|
||||||
|
// الگو: getStateInfo مقدار host را هم برگرداند تا صفحات دوباره parse نکنند
|
||||||
|
delete params.city; delete params.state; // روی دامنه نماینده، فیلتر شهر بیمعنی است
|
||||||
|
}
|
||||||
|
doctors = await fetchReq(`${API_URL}/api/v1/doctors`, { params });
|
||||||
|
```
|
||||||
|
|
||||||
|
- `getStateInfo` فیلد `host` را هم برگرداند (نرمالشده) تا هیچ صفحهای خودش `headers()` را برای دامنه parse نکند — همراستا با اصل «سرویس مرکزی دامنه» در backend.
|
||||||
|
- جستجوی صفحه اصلی (`components/home/search/*`) که client-side به `/api/v1/doctors` میزند: از `window.location.hostname` همان پارامتر `domain` را وقتی سایتِ rep است اضافه کند — تشخیص client-side: مقدار repContext از server از طریق props/context (سادهترین راه: `ProvinceProvider` یا prop از layout؛ الگوی موجود client-side پروژه را دنبال کن).
|
||||||
|
|
||||||
|
### ۳. متادیتا و برندینگ دامنه نماینده
|
||||||
|
|
||||||
|
- `app/layout.js` و `generateMetadata` صفحات doctors/clinics: وقتی `repContext` هست:
|
||||||
|
- `siteName = repContext.full_name`
|
||||||
|
- title الگو: `نوبتدهی آنلاین پزشکان | ${repContext.full_name}`
|
||||||
|
- description عمومی (بدون نام شهر).
|
||||||
|
- Header/Footer: جایی که `matchedCity?.site_name` مصرف میشود (`components/layout/*`, `app/component/Logo.js`) fallback به `repContext?.full_name` قبل از «نوبت 724».
|
||||||
|
- صفحات وابسته به شهر (مثل انتخاب شهر در سرچ): روی دامنه rep رفتار «ریشه» (همه شهرها) بماند — گیت اضافه نزن؛ فقط لیست نتایج فیلتر میشود.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **هیچ regression روی دامنههای شهری**: مسیر `matchedCity` پیدا شد → `fetchSiteContext` اصلاً صدا زده نشود؛ رفتار فعلی بایتبهبایت حفظ.
|
||||||
|
- `DEV_MODE=TRUE` مثل قبل noindex — دامنههای rep هم مشمول همان robots.
|
||||||
|
- تست local: `HOST=x-nobat.localhost npm run dev` کار نمیکند مگر backend لوکال یک rep با دامنه `x-nobat.localhost` داشته باشد — در گزارش، دستور ساخت rep تستی (از پنل ادمین clinicpro لوکال) را ذکر کن.
|
||||||
|
- خطای API سایتکانتکست → سایت مثل دامنه ناشناخته (رفتار فعلی) — هرگز 500 نشود (درس صفحه contact-us).
|
||||||
|
- build کامل (`npm run build`) و تست دستی سه حالت: دامنه شهر (yazd-nobat.localhost)، دامنه ریشه، دامنه ناشناخته.
|
||||||
|
- بعد از پیادهسازی: مستندات backend (`clinicpro/docs/api/doctor.md`/`clinic.md`) باید با مصرف واقعی این فرانت همخوان باشد — اگر اختلافی دیدی همانجا اصلاح کن.
|
||||||
|
- **عملیاتی**: هر دامنه نماینده سراسری باید در Coolify به سرویس فرانت و به `ALLOWED_FRONTEND_HOSTS` بکاند اضافه شود (CORS/TLS) — در گزارش نهایی یادآوری کن.
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# رفع لینک `/doctor/undefined` و متمایزسازی صفحهٔ اصلی شهرها
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — سایت عمومی. بدون تغییر بکاند.
|
||||||
|
|
||||||
|
سند همراه: `docs/seo/gsc-expected-exclusions.md` — آن خطاهای Search Console که **باگ نیستند**
|
||||||
|
و نباید «رفع» شوند. این پرامپت فقط دو موردِ واقعاً کدی را میسازد.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
Search Console روی `behbahan-nobat.ir` و `yasuj-nobat.ir` شش دستهٔ مسئله نشان میدهد.
|
||||||
|
چهار دسته رفتار عمدی سیستماند و در سند بالا توضیح داده شدهاند. دو دسته باگ واقعیاند:
|
||||||
|
|
||||||
|
- **Not found (404)** — `https://behbahan-nobat.ir/doctor/undefined`، آخرین crawl ۳۰ ژوئیه
|
||||||
|
- **Duplicate, Google chose different canonical than user** — `https://behbahan-nobat.ir/`
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
### ۱. لینک `/doctor/undefined`
|
||||||
|
|
||||||
|
اسکلت بارگذاری صفحهٔ پزشکان، شش کارت با آبجکتِ بدون `uuid` رندر میکند. `ItemDoctor`
|
||||||
|
لینک را با `doctor?.uuid` میسازد، پس در HTML شش `<a href="/doctor/undefined">` مینشیند و
|
||||||
|
Googlebot همان را crawl میکند.
|
||||||
|
|
||||||
|
زنده تأیید شد:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -o /dev/null -w "%{http_code}" https://behbahan-nobat.ir/doctor/undefined
|
||||||
|
404
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. صفحهٔ اصلی هر شهر تقریباً کپی بقیه است
|
||||||
|
|
||||||
|
canonical درست و self است — این را از خودِ سایت زنده گرفتم:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://behbahan-nobat.ir/ → <link rel="canonical" href="https://behbahan-nobat.ir">
|
||||||
|
https://yasuj-nobat.ir/ → <link rel="canonical" href="https://yasuj-nobat.ir">
|
||||||
|
```
|
||||||
|
|
||||||
|
پس گوگل canonical ما را **رد کرده**، نه اینکه ما اشتباه اعلام کرده باشیم. دلیلش اندازهگیری شد:
|
||||||
|
|
||||||
|
```
|
||||||
|
شباهت متنِ رندرشدهٔ دو صفحهٔ اصلی: ۹۹.۰٪
|
||||||
|
```
|
||||||
|
|
||||||
|
تنها چیزِ شهرمحورِ صفحهٔ اصلی، `site_name` و `slogan` از `data/city.json` است. بقیه —
|
||||||
|
بنر، کادر جستجو، «جستجوهای پرتکرار»، فوتر — روی همهٔ دامنهها یکسان است. تا محتوا
|
||||||
|
متمایز نشود هیچ تگی جلوی تجمیع را نمیگیرد؛ همین ریشهٔ بخش بزرگی از
|
||||||
|
`Discovered - currently not indexed` هم هست.
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
|
||||||
|
- ✅ موفق: در HTML صفحهٔ `/doctors` (چه در حالت اسکلت چه با داده) هیچ
|
||||||
|
`href="/doctor/undefined"` نباشد.
|
||||||
|
- ✅ موفق: صفحهٔ اصلی هر شهر یک بخش متنی و آماری دارد که با شهر دیگر فرق میکند —
|
||||||
|
شمار پزشک و کلینیک همان شهر، و تخصصهای پرتکرارِ همان شهر.
|
||||||
|
- ✅ موفق: شباهت متنِ رندرشدهٔ دو صفحهٔ اصلی به زیر ۸۵٪ برسد (سنجهٔ عینی، دستور پایین).
|
||||||
|
- ❌ خطا: اگر API پاسخ ندهد یا شهر پزشکی نداشته باشد، صفحهٔ اصلی نباید بشکند و نباید
|
||||||
|
عدد صفر یا «undefined» نشان دهد — بخش آمار حذف میشود و بقیهٔ صفحه سالم میماند.
|
||||||
|
- ❌ خطا: کارت اسکلت (بدون `uuid`) نباید اصلاً لینک باشد.
|
||||||
|
- ⚠️ مرزی: دامنهٔ ریشه `nobat724.com` شهر ندارد؛ متن و آمار شهری نباید آنجا رندر شود.
|
||||||
|
- ⚠️ مرزی: شهری که تازه اضافه شده و صفر پزشک دارد — پیام بیجایگزین نه، بلکه حذف بخش آمار.
|
||||||
|
- ⚠️ مرزی: `loading.js` روی مسیر `/doctors` است؛ اسکلتهای دیگری که `ItemDoctor` را با
|
||||||
|
دادهٔ ناقص رندر میکنند هم باید همین رفتار را بگیرند.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `app/component/ItemDoctor.js` | سازندهٔ لینک پزشک — تنها جای پروژه |
|
||||||
|
| `app/doctors/loading.js` | اسکلتی که آبجکت بدون `uuid` میدهد |
|
||||||
|
| `components/home/index.js` | چیدمان صفحهٔ اصلی |
|
||||||
|
| `components/home/TextHeader.js` | تنها بخش شهرمحور فعلی |
|
||||||
|
| `components/home/FrequentSearches.js` | فهرست ثابت، یکسان روی همهٔ دامنهها |
|
||||||
|
| `lib/getStateInfo.js` | تشخیص شهر از subdomain (server) |
|
||||||
|
| `lib/rootCity.js` | `isRootCity` — تشخیص دامنهٔ ریشه |
|
||||||
|
| `lib/specialtyContent.js` | `buildSpecialtyIntro` — الگوی متنِ یکتای موجود |
|
||||||
|
| `services/response.js` | `getSpecialtyDoctorCounts` — شمار پزشک هر تخصص در یک شهر |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
### لینک با uuid اختیاری
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// app/component/ItemDoctor.js:41
|
||||||
|
<Link href={`/doctor/${doctor?.uuid}`}>
|
||||||
|
<p className="text-[#3B3B3B] text-[14px] font-bold">
|
||||||
|
{doctor?.display_name || doctor?.name}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// app/component/ItemDoctor.js:89
|
||||||
|
<Link
|
||||||
|
href={`/doctor/${doctor?.uuid}`}
|
||||||
|
aria-label={`مشاهده پروفایل ${doctor?.display_name || doctor?.name || "پزشک"}`}
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
### اسکلتی که uuid ندارد
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// app/doctors/loading.js
|
||||||
|
{Array.from({ length: 6 }).map((_, idx) => (
|
||||||
|
<ItemDoctor key={idx} doctor={{ id: idx }} loading setDoctors={() => {}} />
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
|
||||||
|
### تنها بخش شهرمحور صفحهٔ اصلی
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// components/home/TextHeader.js
|
||||||
|
const { matchedCity } = await getStateInfo();
|
||||||
|
...
|
||||||
|
<p className="…">با {matchedCity?.site_name}</p>
|
||||||
|
<h1 className="…">{matchedCity?.slogan}</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// components/home/index.js
|
||||||
|
<div className="padding-responsive z-20 min-h-screen py-[120px] flex items-center justify-center flex-col gap-[40px]">
|
||||||
|
<TextHeader />
|
||||||
|
<SearchBar />
|
||||||
|
<FrequentSearches />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### الگوی متنِ یکتا که از قبل هست و باید تقلید شود
|
||||||
|
|
||||||
|
```js
|
||||||
|
// lib/specialtyContent.js:57
|
||||||
|
export function buildSpecialtyIntro(specialtyName, cityName, doctorCount = 0) {
|
||||||
|
const seed = hash(specialtyName, cityName);
|
||||||
|
return [
|
||||||
|
pick(openingVariants(specialtyName, cityName), seed),
|
||||||
|
pick(bodyVariants(specialtyName, cityName, doctorCount), seed >>> 3),
|
||||||
|
pick(closingVariants(specialtyName, cityName), seed >>> 7),
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. کارت بدون `uuid` نباید لینک باشد
|
||||||
|
|
||||||
|
در `app/component/ItemDoctor.js` هر دو `Link` مشروط شوند. وقتی `uuid` نیست — اسکلت
|
||||||
|
بارگذاری، یا رکورد ناقص — همان محتوا بدون `<a>` رندر شود.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const href = doctor?.uuid ? `/doctor/${doctor.uuid}` : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
- عنوان: اگر `href` تهی بود، `<p>` بدون `Link`.
|
||||||
|
- دکمهٔ فلش: اگر `href` تهی بود، دکمه رندر نشود (در اسکلت هم معنایی ندارد).
|
||||||
|
|
||||||
|
`?.` را از `doctor?.uuid` داخل رشتهٔ الگو حذف کن؛ همان بود که `undefined` را به URL میبرد.
|
||||||
|
|
||||||
|
**نحوه تست:** تست کامپوننتی در `app/component/ItemDoctor.test.js` —
|
||||||
|
پزشک با `uuid` دو لینک به `/doctor/<uuid>` دارد؛ آبجکت `{ id: 0 }` هیچ `role="link"`
|
||||||
|
ندارد و رشتهٔ `undefined` در `container.innerHTML` نیست.
|
||||||
|
سپس build و بررسی HTML واقعی:
|
||||||
|
```bash
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build
|
||||||
|
npm run start &
|
||||||
|
curl -s http://localhost:3000/doctors | grep -c 'doctor/undefined' # باید 0 باشد
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. بخش شهرمحور صفحهٔ اصلی
|
||||||
|
|
||||||
|
کامپوننت تازه `components/home/CityHighlights.js` — سرور-کامپوننت، زیر `FrequentSearches`.
|
||||||
|
|
||||||
|
داده از `GET /api/v1/specialties/doctor-counts?city_id=<id>` میآید که از قبل هست و
|
||||||
|
`services/response.js` هم wrapper دارد (`getSpecialtyDoctorCounts`). برای صفحهٔ اصلی
|
||||||
|
server-side است، پس با `lib/req.js` → `fetchReq` بگیر، نه با کلاینت axios.
|
||||||
|
|
||||||
|
محتوا:
|
||||||
|
|
||||||
|
- یک پاراگراف یکتا با همان الگوی `buildSpecialtyIntro`: تابع تازهای کنارش در
|
||||||
|
`lib/specialtyContent.js` به نام `buildCityIntro(cityName, doctorCount, topSpecialties)`
|
||||||
|
بنویس. **الگو Variant-pick با hash است، نه متن ثابت** — دلیلش همان دلیل تابع موجود:
|
||||||
|
متنِ یکسان روی ۳۰ دامنه دوباره همان duplicate را میسازد؛ seed از نام شهر میآید تا
|
||||||
|
هر شهر واگرا شود و خروجی هم پایدار بماند.
|
||||||
|
- شمار واقعی: «N پزشک در M تخصص در <شهر>».
|
||||||
|
- شش تا هشت تخصصِ پرپزشکِ **همان شهر** با شمارشان، لینک به `/specialties/<slug>`.
|
||||||
|
این جای `FrequentSearches` را نمیگیرد؛ آن فهرست ثابت است و این یکی شهرمحور.
|
||||||
|
|
||||||
|
روی دامنهٔ ریشه (`isRootCity`) کل بخش رندر نشود — آنجا شهری وجود ندارد.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const { matchedCity, isRoot } = await getStateInfo();
|
||||||
|
if (isRoot || !matchedCity?.id) return null;
|
||||||
|
|
||||||
|
const counts = await safeCounts(matchedCity.id); // خطا → آرایهٔ خالی
|
||||||
|
if (counts.length === 0) return null; // شهر بدون پزشک → بخش حذف
|
||||||
|
```
|
||||||
|
|
||||||
|
خطا را همینجا ببلع و `null` برگردان. صفحهٔ اصلی مهمترین صفحهٔ سایت است و نباید با
|
||||||
|
قطعی API سفید شود؛ این همان مرز واقعی error handling است که پروژه میپذیرد.
|
||||||
|
|
||||||
|
**نحوه تست:**
|
||||||
|
- unit test برای `buildCityIntro` در `lib/specialtyContent.test.js`: دو شهر متفاوت متن
|
||||||
|
متفاوت بدهند؛ یک شهر در دو فراخوانی متن یکسان بدهد (پایداری)؛ `doctorCount` صفر
|
||||||
|
جمله را نشکند.
|
||||||
|
- تست کامپوننتی `components/home/CityHighlights.test.js` با mock روی `getStateInfo` و
|
||||||
|
`fetchReq`: دامنهٔ شهری بخش را میسازد؛ دامنهٔ ریشه `null`؛ خطای شبکه `null`؛
|
||||||
|
فهرست خالی `null`.
|
||||||
|
- سنجهٔ عینیِ معیار پذیرش، بعد از deploy:
|
||||||
|
```bash
|
||||||
|
a=$(curl -s https://behbahan-nobat.ir/ | sed 's/<[^>]*>/ /g' | tr -s ' \n' ' ')
|
||||||
|
b=$(curl -s https://yasuj-nobat.ir/ | sed 's/<[^>]*>/ /g' | tr -s ' \n' ' ')
|
||||||
|
python3 -c "
|
||||||
|
import sys, difflib
|
||||||
|
print('%.1f%%' % (difflib.SequenceMatcher(None, sys.argv[1], sys.argv[2]).ratio()*100))
|
||||||
|
" "$a" "$b"
|
||||||
|
```
|
||||||
|
پیش از تغییر ۹۹٫۰٪ بود؛ باید زیر ۸۵٪ برود.
|
||||||
|
|
||||||
|
### ۳. `generateMetadata` صفحهٔ اصلی
|
||||||
|
|
||||||
|
`app/page.js` الان `generateMetadata` ندارد و از layout ارث میبرد. توضیحات متا هم
|
||||||
|
باید شهرمحور باشد، وگرنه snippet هر ۳۰ دامنه یکی است.
|
||||||
|
|
||||||
|
`description` را از همان `buildCityIntro` بساز (کوتاهشده)، و `alternates.canonical`
|
||||||
|
را دست نزن — لایهٔ layout با `getCanonicalUrl` درستش میکند و صفحهٔ اصلی C1-a است.
|
||||||
|
|
||||||
|
**نحوه تست:** `curl -s https://<city>-nobat.ir/ | grep -oE '<meta name="description" content="[^"]*"'`
|
||||||
|
روی دو دامنه، و متفاوت بودنشان.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **قرارداد API عوض نمیشود.** `doctor-counts` از قبل وجود دارد و مستند است. هیچ کاری در
|
||||||
|
`clinicpro` لازم نیست.
|
||||||
|
- **الگو: Variant-pick با hash** — همان که `lib/specialtyContent.js` برای صفحات تخصص
|
||||||
|
دارد. دلیل انتخابش این است که مسئله دقیقاً همان است: یک قالب روی دهها دامنه که اگر
|
||||||
|
ثابت بماند دوباره duplicate میسازد. متن تصادفیِ ناپایدار هم بدتر است، چون هر
|
||||||
|
rebuild محتوا را عوض میکند؛ seed از نام شهر هر دو را حل میکند.
|
||||||
|
- **بخش آمار نباید عدد صفر نشان دهد.** شهرِ بدون پزشک با «۰ پزشک» بدتر از نبودِ بخش است.
|
||||||
|
- **`FrequentSearches` دستنخورده بماند.** آن فهرست ثابتِ تخصصهاست و کارکرد ناوبری دارد؛
|
||||||
|
بخش تازه مکمل آن است نه جایگزینش.
|
||||||
|
- **این تغییر SEO است و اثرش فوری نیست.** بعد از deploy باید در Search Console دوباره
|
||||||
|
ایندکس درخواست شود و هفتهها طول میکشد. در گزارش پایانی این را صریح بنویس تا انتظار
|
||||||
|
اشتباه ساخته نشود.
|
||||||
|
- **`Validate fix` را برای دستههای دیگر نزن.** دلیلش در سند همراه آمده؛ زدنش روی
|
||||||
|
noindex عمدی همیشه شکست میخورد و در Search Console نویز میسازد.
|
||||||
|
- خط پایهٔ فعلی ریپو پیش از این تسک: `npm run test` چهار شکستِ ازقبلموجود در
|
||||||
|
`lib/lib.test.js` و `lib/getStateInfo.test.js`، و `npm run lint` سه خطا در فایلهای
|
||||||
|
بیربط. اینها رگرسیون نیستند و درست کردنشان در محدودهٔ این تسک نیست.
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
# بازگرداندن صفحهٔ اصلی، رفع ۴۰۴ اسلاگ فارسی، سه اصلاح صفحهٔ مقاله، و دو اصلاح «درباره ما»
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — سایت عمومی. بدون تغییر بکاند.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
پنج مورد جدا که کاربر روی محیط محلی دید. یکی بازگرداندن تغییری است که در تسک قبلی
|
||||||
|
اضافه شد، یکی باگ ۴۰۴ با ریشهٔ اثباتشده، و سه اصلاح کوچک روی صفحهٔ مقاله.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
### ۱. بخش تازهٔ صفحهٔ اصلی برداشته شود
|
||||||
|
|
||||||
|
در تسک قبلی برای رفع `Duplicate, Google chose different canonical` یک بخش شهرمحور
|
||||||
|
به صفحهٔ اصلی اضافه شد: متن مقدمه، «تخصصهای پرمراجعه در …» و «پزشکان …».
|
||||||
|
کاربر آن را نمیخواهد. صفحهٔ اصلی به حالت قبل برگردد.
|
||||||
|
|
||||||
|
### ۲. اسلاگ فارسی ۴۰۴ میدهد
|
||||||
|
|
||||||
|
```
|
||||||
|
/specialties/%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4 → 404
|
||||||
|
/specialties/gastroenterology → 200
|
||||||
|
```
|
||||||
|
|
||||||
|
**ریشه با ابزارگذاری روی build واقعی اثبات شد** — `generateMetadata` و بدنهٔ صفحه دو
|
||||||
|
مقدار متفاوت میگیرند:
|
||||||
|
|
||||||
|
```
|
||||||
|
generateMetadata → slug = "جراح-گوارش" found: true
|
||||||
|
بدنهٔ صفحه → slug = "%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4" found: false → notFound()
|
||||||
|
```
|
||||||
|
|
||||||
|
یعنی Next در بدنهٔ صفحه segment را decode نمیکند. رشتهها بایتبهبایت یکیاند
|
||||||
|
(هر دو NFC، بدون تفاوت codepoint)، پس مشکل نرمالسازی نیست.
|
||||||
|
|
||||||
|
**این باگ فقط مخصوص صفحهٔ تخصص نیست.** slug مقالهها هم فارسی است
|
||||||
|
(`آلرژی-دارویی-…-58d2e880`)، پس `app/blog/[slug]/page.js` هم همین را دارد و امروز
|
||||||
|
با uuid تست میشود که ASCII است و مشکل را پنهان میکند.
|
||||||
|
|
||||||
|
### ۳. برچسب «مخصوص شهر» از صفحهٔ مقاله حذف شود
|
||||||
|
|
||||||
|
فقط همین جمله. منطق scope شهریِ مقالهها دستنخورده میماند.
|
||||||
|
|
||||||
|
### ۴. تگهای مقاله لینک شوند
|
||||||
|
|
||||||
|
الان چیپهای بدون لینکاند. کامنت کد میگوید «صفحهٔ فیلتر تگ وجود ندارد» — این کامنت
|
||||||
|
کهنه است؛ `components/blogs/index.js` از قبل `?tag=` را میخواند و اعمال میکند.
|
||||||
|
|
||||||
|
### ۵. عکس «مطالب مرتبط» دفرمه است
|
||||||
|
|
||||||
|
عکس داخل `<li className="flex">` است و ظرفش `shrink-0` ندارد. با تیتر بلند، عرض عکس
|
||||||
|
فشرده میشود و ارتفاعش ثابت میماند.
|
||||||
|
|
||||||
|
### ۶. صفحهٔ «درباره ما» راه عضویت پزشک را نمیگوید
|
||||||
|
|
||||||
|
مسیر صفحه:
|
||||||
|
|
||||||
|
```
|
||||||
|
/about-us
|
||||||
|
```
|
||||||
|
|
||||||
|
متن فعلی فقط از دید بیمار نوشته شده. پزشکی که بخواهد به سامانه اضافه شود هیچ
|
||||||
|
راهنماییای نمیبیند. باید بند تازهای اضافه شود که او را به این سایت بفرستد:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://clinic-pro.ir/
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۷. لوگوی هدر «درباره ما» غلط است
|
||||||
|
|
||||||
|
هدر بنفش صفحهٔ «درباره ما» فایل قدیمی را نشان میدهد:
|
||||||
|
|
||||||
|
```
|
||||||
|
/assets/images/logo-2.png
|
||||||
|
```
|
||||||
|
|
||||||
|
هیچجای دیگر سایت این فایل را مصرف نمیکند. لوگوی واقعی سایت این است:
|
||||||
|
|
||||||
|
```
|
||||||
|
/nobat724.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
که هدر اصلی سایت در `app/component/Logo.js` از آن استفاده میکند.
|
||||||
|
|
||||||
|
**دو نکته که سادهترین راهحل را میشکنند:**
|
||||||
|
|
||||||
|
اول، رنگ stroke لوگو `#5559CE` است و پسزمینهٔ هدر `#5559C2`. اگر همان svg را
|
||||||
|
مستقیم بگذارید، لوگو تقریباً نامرئی میشود. باید نسخهٔ روشن رندر شود.
|
||||||
|
|
||||||
|
دوم، `SiteLogo` از قبل همین کار را میکند: هم `city.logo_url` را در نظر میگیرد و
|
||||||
|
هم `stroke` را پارامتری گرفته. مسیرش:
|
||||||
|
|
||||||
|
```
|
||||||
|
components/doctor/poster/SiteLogo.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
|
||||||
|
- ✅ موفق: `/specialties/%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4`
|
||||||
|
کد `200` بدهد و تیتر «متخصص جراح گوارش در …» را نشان دهد.
|
||||||
|
- ✅ موفق: `/specialties/gastroenterology` همچنان `200` — اسلاگ لاتین نشکند.
|
||||||
|
- ✅ موفق: صفحهٔ مقاله دیگر «مخصوص شهر:» ندارد، ولی نویسنده و تاریخ سر جایشاناند.
|
||||||
|
- ✅ موفق: هر تگ مقاله لینک به `/blogs?tag=<نام تگ>` است و آن صفحه همان فیلتر را اعمال میکند.
|
||||||
|
- ✅ موفق: HTML صفحهٔ اصلی هیچکدام از «تخصصهای پرمراجعه در»، «پزشکان <شهر>» را ندارد.
|
||||||
|
- ❌ خطا: اسلاگ ناموجود — چه فارسی چه لاتین — همچنان `404` بدهد، نه `200` یا `500`.
|
||||||
|
- ❌ خطا: مقالهای بدون تگ، بلوک تگ را اصلاً رندر نکند.
|
||||||
|
- ⚠️ مرزی: اسلاگ فارسی که با `%` شروع نمیشود ولی حرف فارسی خام دارد هم کار کند.
|
||||||
|
- ⚠️ مرزی: تگ حاوی فاصله یا `&` باید در URL درست encode شود.
|
||||||
|
- ⚠️ مرزی: عکس مطالب مرتبط با تیتر بسیار بلند نباید باریک شود؛ نسبت ابعاد ثابت بماند.
|
||||||
|
- ✅ موفق: صفحهٔ `/about-us` بندی دارد که پزشک را به `https://clinic-pro.ir/` میفرستد،
|
||||||
|
و آن آدرس لینک واقعی است نه متن ساده.
|
||||||
|
- ✅ موفق: لینک عضویت پزشک `target="_blank"` و `rel="noopener noreferrer"` دارد.
|
||||||
|
- ✅ موفق: هیچ ارجاعی به `logo-2.png` در کد نماند و لوگوی هدر «درباره ما» روی
|
||||||
|
پسزمینهٔ بنفش دیده شود.
|
||||||
|
- ❌ خطا: نام سایت در بند تازه هاردکد نشود؛ همان `siteName` که بقیهٔ صفحه استفاده میکند.
|
||||||
|
- ⚠️ مرزی: شهری که `logo_url` دارد باید لوگوی خودش را در هدر ببیند، نه لوگوی پیشفرض.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/home/index.js` | چیدمان صفحهٔ اصلی — `CityHighlights` از اینجا حذف شود |
|
||||||
|
| `components/home/CityHighlights.js` | کامپوننتی که باید برداشته شود |
|
||||||
|
| `components/home/CityHighlights.test.js` | تستش هم با آن میرود |
|
||||||
|
| `lib/listingIntro.js` | `buildCityIntro` و `CITY_INTROS` — فقط مصرفکنندهشان صفحهٔ اصلی بود |
|
||||||
|
| `lib/cityIntro.test.js` | تستهای `buildCityIntro` |
|
||||||
|
| `app/specialties/[slug]/page.js` | ۴۰۴ اسلاگ فارسی |
|
||||||
|
| `app/blog/[slug]/page.js` | همان باگ، پنهان چون با uuid تست میشود |
|
||||||
|
| `components/blog/head/index.js` | برچسب «مخصوص شهر» و چیپهای تگ |
|
||||||
|
| `components/blogs/index.js` | خوانندهٔ `?tag=` — تأیید میکند لینک تگ کار میکند |
|
||||||
|
| `components/blog/relatedContent/Item.js` | عکس دفرمه |
|
||||||
|
| `components/aboutUs/index.js` | متن «درباره ما» — بند تازهٔ پزشک اینجا اضافه شود |
|
||||||
|
| `components/aboutUs/Head.js` | هدر بنفش با لوگوی غلط |
|
||||||
|
| `components/doctor/poster/SiteLogo.js` | لوگوی درست، `stroke` پارامتری، پشتیبانی `logo_url` |
|
||||||
|
| `app/component/Logo.js` | لوگوی هدر اصلی — مرجع اینکه لوگوی واقعی کدام است |
|
||||||
|
| `public/assets/images/logo-2.png` | فایل یتیم — بعد از اصلاح هیچ مصرفکنندهای ندارد |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
### صفحهٔ اصلی
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// components/home/index.js
|
||||||
|
<div className="padding-responsive z-20 min-h-screen py-[120px] flex items-center justify-center flex-col gap-[40px]">
|
||||||
|
<TextHeader />
|
||||||
|
<SearchBar />
|
||||||
|
<FrequentSearches />
|
||||||
|
<CityHighlights />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### صفحهٔ تخصص — دو مصرف متفاوت از یک param
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// app/specialties/[slug]/page.js:18
|
||||||
|
const findSpecialty = (slug) =>
|
||||||
|
specialtiesData.find((item) => item.slug === slug && item.status === 1) ?? null;
|
||||||
|
|
||||||
|
// :45 generateMetadata — اینجا decode شده میرسد
|
||||||
|
const { slug } = await params;
|
||||||
|
const specialty = findSpecialty(slug);
|
||||||
|
if (!specialty) notFound();
|
||||||
|
|
||||||
|
// :78 بدنهٔ صفحه — اینجا encode شده میرسد
|
||||||
|
const { slug } = await params;
|
||||||
|
const specialty = findSpecialty(slug);
|
||||||
|
if (!specialty) notFound();
|
||||||
|
```
|
||||||
|
|
||||||
|
### برچسب شهر
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// components/blog/head/index.js:49
|
||||||
|
{cityName && (
|
||||||
|
<div className="flex items-center justify-start gap-1">
|
||||||
|
<p className="text-[#9B9B9B] text-[11px] md:text-[13px] lg:text-[14px] font-normal">
|
||||||
|
مخصوص شهر:
|
||||||
|
</p>
|
||||||
|
<p className="text-[#9B9B9B] text-[11px] md:text-[14px] lg:text-[16px] font-medium">
|
||||||
|
{cityName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### تگهای بدون لینک
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// components/blog/head/index.js:90
|
||||||
|
{/* فهرست کامل تگها — تا این تغییر فقط تگ اول در بردکرامب دیده میشد.
|
||||||
|
صفحهٔ فیلتر تگ وجود ندارد، پس چیپها بدون لینک رندر میشوند. */}
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<ul className="flex flex-wrap items-center justify-start gap-2 mt-[12px] md:mt-[16px]">
|
||||||
|
{tags.map((tag, idx) => (
|
||||||
|
<li
|
||||||
|
key={`${tag}-${idx}`}
|
||||||
|
className="bg-[#F5F5F5] text-[#525252] rounded-full px-[12px] py-[4px] text-[11px] md:text-[13px] font-medium"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
ولی فیلتر تگ از قبل هست:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// components/blogs/index.js:16
|
||||||
|
const selectedTag = searchParams.get("tag") || null;
|
||||||
|
// :32
|
||||||
|
params.tag = selectedTag;
|
||||||
|
```
|
||||||
|
|
||||||
|
### عکس مطالب مرتبط
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// components/blog/relatedContent/Item.js
|
||||||
|
<li className="flex w-full relative items-start justify-start gap-[8px] py-[12px]">
|
||||||
|
<CustomLoading width={80} height={72} loading={loading}>
|
||||||
|
<Link href={`/blog/${data.uuid}`}>
|
||||||
|
<img
|
||||||
|
className=" w-[64px] sm:w-[73px] md:w-[85px] lg:w-[97px] h-[56px] sm:h-[64px] md:h-[75px] lg:h-[88px] rounded-[8px] overflow-hidden object-cover "
|
||||||
|
src={cover}
|
||||||
|
alt={data.title || "article"}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</CustomLoading>
|
||||||
|
```
|
||||||
|
|
||||||
|
### هدر «درباره ما»
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// components/aboutUs/Head.js
|
||||||
|
<div className="bg-[#5559C2] rounded-none md:rounded-lg w-full py-[24px] flex flex-col items-center justify-center gap-[13px] md:gap-[16px]">
|
||||||
|
<Image
|
||||||
|
src="/assets/images/logo-2.png"
|
||||||
|
height={46}
|
||||||
|
width={46}
|
||||||
|
alt="logo"
|
||||||
|
/>
|
||||||
|
<h1 className="...">درباره {siteName}</h1>
|
||||||
|
{slogan && <p className="...">{slogan}</p>}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
`Head` الان فقط `siteName` و `slogan` میگیرد؛ `matchedCity` به آن پاس نمیشود.
|
||||||
|
|
||||||
|
### متن «درباره ما»
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// components/aboutUs/index.js — دو بند، هر دو از دید بیمار
|
||||||
|
<p>{siteName} یک سامانهی آنلاین نوبتدهی پزشکی است که …</p>
|
||||||
|
<p>در {siteName} میتوانید بر اساس تخصص، نام پزشک یا مرکز درمانی جستجو کنید …</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. برداشتن بخش شهرمحور صفحهٔ اصلی
|
||||||
|
|
||||||
|
- `<CityHighlights />` و importش از `components/home/index.js` حذف شود.
|
||||||
|
- فایل `components/home/CityHighlights.js` و تستش پاک شوند.
|
||||||
|
- `buildCityIntro` و `CITY_INTROS` در `lib/listingIntro.js` تنها مصرفکنندهشان همین
|
||||||
|
کامپوننت بود؛ با آن پاک شوند. تستهای مربوطه در `lib/cityIntro.test.js` هم بروند.
|
||||||
|
**کد مرده نگه ندارید** — همین اشتباه قبلاً با `buildDoctorsIntro` تکرار شد.
|
||||||
|
- `buildClinicsIntro` و `CLINIC_INTROS` بمانند؛ `/clinics` هنوز مصرفشان میکند.
|
||||||
|
|
||||||
|
**نحوه تست:**
|
||||||
|
```bash
|
||||||
|
grep -rn "CityHighlights\|buildCityIntro" app components lib # باید خالی باشد
|
||||||
|
npm run test && npm run lint
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build && npm run start &
|
||||||
|
curl -s -H "Host: yasuj-nobat.ir" http://localhost:3000/ | grep -cE "تخصصهای پرمراجعه|پزشکان یاسوج" # باید 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. رفع ۴۰۴ اسلاگ فارسی
|
||||||
|
|
||||||
|
یک helper مشترک بسازید — این باگ در دو صفحه هست و فردا در صفحهٔ سوم هم میآید:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// lib/routeParams.js
|
||||||
|
/**
|
||||||
|
* segment مسیر را به شکل decodeشده برمیگرداند.
|
||||||
|
*
|
||||||
|
* Next در `generateMetadata` مقدار decodeشده میدهد ولی در بدنهٔ همان صفحه مقدار
|
||||||
|
* خام؛ روی اسلاگهای فارسی این یعنی متادیتا درست ساخته میشود و بلافاصله بعدش
|
||||||
|
* صفحه ۴۰۴ میدهد. اثباتشده روی build واقعی، نه فرض.
|
||||||
|
*
|
||||||
|
* decode شکستخورده (درصدِ ناقص در ورودی) نباید صفحه را بشکند؛ همان مقدار خام
|
||||||
|
* برمیگردد تا نتیجهاش ۴۰۴ طبیعی باشد نه ۵۰۰.
|
||||||
|
*/
|
||||||
|
export function decodeRouteParam(value) {
|
||||||
|
if (typeof value !== "string" || !value.includes("%")) return value;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
سپس در هر دو صفحه، بلافاصله بعد از `await params`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { slug: rawSlug } = await params;
|
||||||
|
const slug = decodeRouteParam(rawSlug);
|
||||||
|
```
|
||||||
|
|
||||||
|
در `app/specialties/[slug]/page.js` هر دو جا (`generateMetadata` و بدنه) و در
|
||||||
|
`app/blog/[slug]/page.js` هم هر دو جا.
|
||||||
|
|
||||||
|
**مواظب `slug` که به کامپوننت پاس میشود باشید:** در صفحهٔ تخصص، `slug` به
|
||||||
|
`SpecialtyDetailPage` میرود و آنجا در breadcrumb به `/specialties/${slug}` تبدیل
|
||||||
|
میشود. مقدار decodeشده آنجا درست است چون `next/link` خودش encode میکند.
|
||||||
|
|
||||||
|
**نحوه تست:**
|
||||||
|
```bash
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build && npm run start &
|
||||||
|
for u in "/specialties/%D8%AC%D8%B1%D8%A7%D8%AD-%DA%AF%D9%88%D8%A7%D8%B1%D8%B4" \
|
||||||
|
"/specialties/gastroenterology" "/specialties/does-not-exist"; do
|
||||||
|
echo -n "$u → "; curl -s -o /dev/null -w "%{http_code}\n" -H "Host: yasuj-nobat.ir" "http://localhost:3000$u"
|
||||||
|
done
|
||||||
|
# انتظار: 200، 200، 404
|
||||||
|
```
|
||||||
|
بهعلاوه unit test برای `decodeRouteParam` در `lib/routeParams.test.js`: مقدار
|
||||||
|
encodeشده، مقدار خام فارسی، مقدار لاتین، رشتهٔ با `%` ناقص (نباید throw کند)،
|
||||||
|
ورودی `undefined`.
|
||||||
|
|
||||||
|
### ۳. حذف برچسب «مخصوص شهر»
|
||||||
|
|
||||||
|
کل بلوک `{cityName && (...)}` در `components/blog/head/index.js` برداشته شود.
|
||||||
|
اگر `cityName` بعد از آن هیچ مصرفی ندارد، محاسبهاش هم پاک شود تا متغیر بلااستفاده نماند.
|
||||||
|
|
||||||
|
منطق scope شهریِ مقالهها (`domainScopeCityId` و `city_id` در fetch) **دست نخورد** —
|
||||||
|
کاربر فقط همین جمله را خواست.
|
||||||
|
|
||||||
|
**نحوه تست:** `curl` صفحهٔ مقاله و `grep -c "مخصوص شهر"` که باید صفر باشد، در حالی که
|
||||||
|
`grep -c "نویسنده:"` همچنان یک است.
|
||||||
|
|
||||||
|
### ۴. لینککردن تگها
|
||||||
|
|
||||||
|
هر چیپ به `/blogs?tag=<نام>` لینک شود. `next/link` خودش encode میکند، ولی چون تگ
|
||||||
|
ممکن است فاصله یا `&` داشته باشد، از شکل شیئی استفاده کنید تا مطمئن شوید:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Link href={{ pathname: "/blogs", query: { tag } }} className="...">
|
||||||
|
{tag}
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
کامنت کهنهٔ بالای بلوک («صفحهٔ فیلتر تگ وجود ندارد») حذف یا اصلاح شود؛ همان کامنت
|
||||||
|
باعث شد این قابلیت جا بماند.
|
||||||
|
|
||||||
|
استایل چیپ عوض نشود، فقط `hover` مناسب اضافه شود چون حالا کلیکشدنی است.
|
||||||
|
|
||||||
|
**نحوه تست:** تست کامپوننتی روی `components/blog/head` — مقاله با دو تگ، دو
|
||||||
|
`role="link"` با `href` شامل `/blogs?tag=`؛ مقاله بدون تگ، هیچ لیستی رندر نشود.
|
||||||
|
سپس دستی: کلیک روی تگ باید به `/blogs?tag=…` برود و همان فیلتر اعمال شود.
|
||||||
|
|
||||||
|
### ۵. رفع دفرمگی عکس مطالب مرتبط
|
||||||
|
|
||||||
|
به ظرف عکس `shrink-0` اضافه شود تا در flex فشرده نشود:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<CustomLoading width={80} height={72} loading={loading}>
|
||||||
|
<Link href={`/blog/${data.uuid}`} className="block shrink-0">
|
||||||
|
<img className="... shrink-0 ..." ... />
|
||||||
|
</Link>
|
||||||
|
</CustomLoading>
|
||||||
|
```
|
||||||
|
|
||||||
|
اگر `CustomLoading` خودش ظرف flex میسازد، `shrink-0` باید روی همان بیرونیترین
|
||||||
|
عنصرِ داخل `<li>` بنشیند — اول ساختار رندرشده را ببینید، بعد کلاس را جای درست بگذارید.
|
||||||
|
|
||||||
|
**نحوه تست:** تست کامپوننتی که کلاس `shrink-0` روی ظرف عکس هست. بهعلاوه بررسی
|
||||||
|
چشمی در مرورگر با یک تیتر بسیار بلند — jsdom چیدمان را اندازه نمیگیرد، پس این مورد
|
||||||
|
را **صادقانه بهعنوان «بررسی چشمی» گزارش کنید**، نه بهعنوان تست خودکار.
|
||||||
|
|
||||||
|
### ۶. بند عضویت پزشک در «درباره ما»
|
||||||
|
|
||||||
|
یک بند سوم بعد از دو بند فعلی در `components/aboutUs/index.js` اضافه شود. متن
|
||||||
|
پیشنهادی:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<p>
|
||||||
|
اگر پزشک هستید و میخواهید در {siteName} نوبتدهی آنلاین داشته باشید، ثبتنام
|
||||||
|
از طریق سامانهی کلینیکپرو انجام میشود. کافی است به{" "}
|
||||||
|
<a
|
||||||
|
href="https://clinic-pro.ir/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-[#5559C2] font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
clinic-pro.ir
|
||||||
|
</a>{" "}
|
||||||
|
مراجعه کنید و حساب مطب خود را بسازید. پس از تأیید، پروفایل و برنامهی
|
||||||
|
نوبتدهی شما روی {siteName} نمایش داده میشود.
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
قواعد:
|
||||||
|
|
||||||
|
- نام سایت هاردکد نشود؛ همان `siteName` بالای فایل استفاده شود.
|
||||||
|
- لینک خارجی است، پس `next/link` لازم نیست؛ `<a>` با `target="_blank"` و
|
||||||
|
`rel="noopener noreferrer"` درست است.
|
||||||
|
- استایل بند از بقیه جدا نشود؛ همان ظرف `flex flex-col gap-[16px]` را میگیرد.
|
||||||
|
|
||||||
|
**نحوه تست:**
|
||||||
|
```bash
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build && npm run start &
|
||||||
|
curl -s -H "Host: yasuj-nobat.ir" http://localhost:3000/about-us | grep -c "clinic-pro.ir" # باید ≥ 1
|
||||||
|
```
|
||||||
|
بهعلاوه تست کامپوننتی: لینکی با `href="https://clinic-pro.ir/"` رندر شود و
|
||||||
|
`rel` شامل `noopener` باشد.
|
||||||
|
|
||||||
|
### ۷. اصلاح لوگوی هدر «درباره ما»
|
||||||
|
|
||||||
|
`<Image src="/assets/images/logo-2.png" />` با `SiteLogo` جایگزین شود:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<SiteLogo city={matchedCity} stroke="#FAFAFA" className="w-[46px] h-[46px]" />
|
||||||
|
```
|
||||||
|
|
||||||
|
- `matchedCity` باید از `components/aboutUs/index.js` به `Head` پاس شود؛ الان
|
||||||
|
فقط `siteName` و `slogan` میرود.
|
||||||
|
- `stroke="#FAFAFA"` لازم است چون رنگ پیشفرض روی پسزمینهٔ بنفش گم میشود.
|
||||||
|
- `crossOrigin="anonymous"` داخل `SiteLogo` برای حالت poster گذاشته شده و اینجا
|
||||||
|
ضرری ندارد؛ دست نزنید.
|
||||||
|
- اگر `SiteLogo` جای بهتری لازم دارد چون دیگر فقط مال poster نیست، جابهجاییاش
|
||||||
|
به `components/common/` قابل قبول است — ولی آنوقت هر دو مصرفکننده باید
|
||||||
|
بهروز شوند و تستها سبز بمانند.
|
||||||
|
- بعد از اصلاح، `public/assets/images/logo-2.png` هیچ مصرفکنندهای ندارد و پاک
|
||||||
|
میشود. **اول با grep ثابت کنید یتیم است، بعد پاک کنید.**
|
||||||
|
|
||||||
|
**نحوه تست:**
|
||||||
|
```bash
|
||||||
|
grep -rn "logo-2" app components public --include="*.js" --include="*.json" # باید خالی باشد
|
||||||
|
```
|
||||||
|
بهعلاوه تست کامپوننتی روی `components/aboutUs/Head`: شهر بدون `logo_url` باید
|
||||||
|
svg درونخطی بدهد، شهر با `logo_url` باید `<img>` با همان آدرس بدهد.
|
||||||
|
بررسی چشمی هم لازم است — دیدهشدن لوگو روی بنفش را jsdom نمیسنجد. آن را
|
||||||
|
**صادقانه بهعنوان «بررسی چشمی» گزارش کنید**.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **۴۰۴ اسلاگ فارسی، مهمترین بخش این تسک است.** اگر فقط صفحهٔ تخصص درست شود و
|
||||||
|
صفحهٔ مقاله جا بماند، همان باگ با اولین مقالهٔ فارسیاسلاگ برمیگردد. هر دو صفحه
|
||||||
|
در همین تسک اصلاح شوند.
|
||||||
|
- **دلیل helper مشترک بهجای دو تکه کد:** دو مصرفکنندهٔ فعلی و یک الگوی تکرارشونده.
|
||||||
|
این abstraction «برای آینده» نیست؛ همین حالا دو جا لازم است.
|
||||||
|
- **حذف کد مرده جزو کار است.** `buildCityIntro` بعد از برداشتن `CityHighlights`
|
||||||
|
بلااستفاده میشود. نگهداشتنش همان وضعیتی را میسازد که `buildDoctorsIntro` ساخته
|
||||||
|
بود: تابعی که تست دارد ولی هیچجا رندر نمیشود.
|
||||||
|
- **سنجهٔ شباهت صفحهٔ اصلی برمیگردد به حدود ۹۰٪.** تسک قبلی آن را به ۷۱٫۵٪ رسانده
|
||||||
|
بود. این عقبگرد خواستهٔ کاربر است و باید در گزارش پایانی صریح ذکر شود، نه بیصدا.
|
||||||
|
مشکل `Duplicate, Google chose different canonical` روی صفحهٔ اصلی برمیگردد.
|
||||||
|
- **دو مورد «درباره ما» مستقل از پنج مورد قبلیاند.** اگر یکی از آنها گیر کرد،
|
||||||
|
بقیه را کامل کنید و همان یکی را صریح گزارش دهید.
|
||||||
|
- **لوگو مسئلهٔ کنتراست است، نه فقط عوضکردن فایل.** stroke لوگو `#5559CE` و
|
||||||
|
پسزمینه `#5559C2` است. اگر بدون `stroke` روشن جایگزین کنید، تستها سبز
|
||||||
|
میشوند ولی کاربر لوگو را نمیبیند.
|
||||||
|
- خط پایهٔ ریپو پیش از این تسک: `npm run test` چهار شکستِ ازقبلموجود در
|
||||||
|
`lib/lib.test.js` و `lib/getStateInfo.test.js`؛ `npm run lint` سه خطا در فایلهای
|
||||||
|
بیربط. اینها رگرسیون نیستند.
|
||||||
|
- `npm run start` هشدار `"next start" does not work with "output: standalone"`
|
||||||
|
میدهد. صفحات رندر میشوند و برای این تستها کافی است، ولی اگر رفتار عجیبی دیدید
|
||||||
|
اول همین را در نظر بگیرید.
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# دیپلوی nobat724_front روی Liara (پلتفرم Next.js)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی، Next.js 15 App Router، چند-دامنهای). Infra/deploy.
|
||||||
|
|
||||||
|
مرجع: [Liara Next.js quick-start](https://docs.liara.ir/paas/nextjs/quick-start/) و [set-envs](https://docs.liara.ir/paas/nextjs/how-tos/set-envs/).
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
سایت باید روی پلتفرم **nextjs** لیارا دیپلوی شود (نه Docker — `Dockerfile`/`docker-compose.yml` موجود برای Coolify هستند و روی پلتفرم nextjs استفاده نمیشوند). لیارا خودش `npm install` و `npm run build` را اجرا میکند، سپس `npm start`. backend قبلاً روی Liara مستقر شده (`https://clinicpro.liara.run`) و این سایت کلاینت همان API است.
|
||||||
|
|
||||||
|
نکات کلیدی پلتفرم nextjs لیارا (از داک):
|
||||||
|
- فقط پروژههای ساختهشده با `create-next-app` پشتیبانی میشوند؛ `package.json` باید اسکریپت استاندارد `dev`/`build`/`start` داشته باشد. ✅ این پروژه دارد.
|
||||||
|
- متغیرهای محیطی **در زمان build هم در دسترساند** — برای `NEXT_PUBLIC_*` که در باندل کلاینت bake میشوند، باید **قبل از اولین build** در کنسول Liara ست شوند.
|
||||||
|
- اگر env بعد از دیپلوی اضافه شد، باید اپ **restart** شود.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
آمادهسازی پروژه برای دیپلوی صحیح روی Liara nextjs، شامل رفع ریسکهای فعلی و افزودن فایلهای لازم.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `next.config.js` | کانفیگ اصلی (images، headers/CSP، `output: 'standalone'`) |
|
||||||
|
| `next.config.mjs` | **فایل تکراریِ خالی — باید حذف شود** |
|
||||||
|
| `package.json` | اسکریپتها؛ افزودن `engines.node` |
|
||||||
|
| `liara.json` (جدید) | کانفیگ پلتفرم Liara |
|
||||||
|
| `.liaraignore` (جدید) | exclude کردن Docker/.env/node_modules از آپلود |
|
||||||
|
| `lib/getStateInfo.js` | تشخیص شهر از `Host` (multi-domain) — نباید تغییر کند، فقط درک شود |
|
||||||
|
| `.env` | فقط مرجع مقادیر؛ روی Liara از کنسول ست میشوند |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
**۱) دو فایل کانفیگ Next همزمان وجود دارد** — `next.config.mjs` خالی است و میتواند کانفیگ واقعی (`output: standalone`، `images`, `headers`) را override/خنثی کند:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// next.config.mjs (خالی و خطرناک)
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
export default nextConfig;
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// next.config.js (کانفیگ واقعی — این باید بماند)
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
images: { remotePatterns: [ /* api.clinic-pro.ir, clinic-pro.ddev.site, ... */ ] },
|
||||||
|
env: { DEV_MODE: process.env.DEV_MODE },
|
||||||
|
output: 'standalone',
|
||||||
|
async headers() { /* CSP که apiOrigin را از NEXT_PUBLIC_API_URL میسازد */ },
|
||||||
|
};
|
||||||
|
module.exports = nextConfig;
|
||||||
|
```
|
||||||
|
|
||||||
|
**۲) تشخیص شهر از Host** (server-side) — روی Liara باید همهی دامنههای شهرها به همین اپ وصل شوند:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// lib/getStateInfo.js
|
||||||
|
const headersList = await headers();
|
||||||
|
const host = headersList.get("host") || "";
|
||||||
|
const subdomain = host.split(".")[0]; // مثلاً "yazd-nobat"
|
||||||
|
// match با city.domain در data/city.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**۳) env فعلی** (`.env` — مرجع):
|
||||||
|
```
|
||||||
|
NEXT_PUBLIC_API_URL=https://clinic-pro.ddev.site # باید به backend پروداکشن تغییر کند
|
||||||
|
DEV_MODE=FALSE
|
||||||
|
NEXT_PUBLIC_CLIENT_ID=...
|
||||||
|
NEXT_PUBLIC_CLIENT_SECRET=...
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0 # فقط dev — روی Liara ست نشود
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. حذف فایل کانفیگ تکراری
|
||||||
|
|
||||||
|
`next.config.mjs` را حذف کن. Next با وجود هر دو فایل رفتار قطعی ندارد و ممکن است نسخهی خالی بارگذاری شود و `output: 'standalone'`، `images.remotePatterns` و `headers()` را از دست بدهد.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm next.config.mjs
|
||||||
|
```
|
||||||
|
سپس با `npm run build` محلی تأیید کن build سالم است و `.next/standalone` ساخته میشود.
|
||||||
|
|
||||||
|
### ۲. تعیین نسخه Node در `package.json`
|
||||||
|
|
||||||
|
لیارا نسخه Node را از `engines` میخواند. Next 15 حداقل Node 18.18 میخواهد؛ Node 20 پیشنهاد میشود:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳. ساخت `liara.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app": "nobat724",
|
||||||
|
"platform": "nextjs",
|
||||||
|
"port": 3000,
|
||||||
|
"build": {
|
||||||
|
"location": "germany"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `port: 3000` — همان پورتی که `next start` پیشفرض روی آن گوش میدهد.
|
||||||
|
- `app` را با نام واقعی اپ Liara یکی کن.
|
||||||
|
|
||||||
|
### ۴. ساخت `.liaraignore`
|
||||||
|
|
||||||
|
تا فایلهای بیربط/حساس آپلود نشوند (Liara خودش `npm install` و build میکند):
|
||||||
|
|
||||||
|
```
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
|
||||||
|
# Docker / Coolify artifacts — روی پلتفرم nextjs استفاده نمیشوند
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
docker-compose.yml
|
||||||
|
|
||||||
|
# Local env & secrets — روی Liara از کنسول ست میشوند
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# tests / dev
|
||||||
|
tests
|
||||||
|
__tests__
|
||||||
|
vitest.config.*
|
||||||
|
coverage
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۵. ست کردن env روی Liara (قبل از اولین build)
|
||||||
|
|
||||||
|
چون `NEXT_PUBLIC_*` در زمان build در باندل کلاینت bake میشوند، **اول** اینها را در کنسول Liara (یا CLI) ست کن، **بعد** deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
liara env:set \
|
||||||
|
NEXT_PUBLIC_API_URL=https://clinicpro.liara.run \
|
||||||
|
NEXT_PUBLIC_CLIENT_ID=<client_id> \
|
||||||
|
NEXT_PUBLIC_CLIENT_SECRET=<client_secret> \
|
||||||
|
DEV_MODE=FALSE \
|
||||||
|
--app nobat724
|
||||||
|
```
|
||||||
|
|
||||||
|
نکات:
|
||||||
|
- `NEXT_PUBLIC_API_URL` → آدرس backend پروداکشن (`https://clinicpro.liara.run` یا دامنهی API اختصاصی). این هم در CSP (`connect-src`) و هم در همهی fetchها استفاده میشود.
|
||||||
|
- `DEV_MODE=FALSE` → اجازهی index شدن توسط موتورهای جستجو (در `app/robots.js` و `app/layout.js` استفاده میشود). اگر staging است، `TRUE` بگذار.
|
||||||
|
- `NODE_TLS_REJECT_UNAUTHORIZED` را روی Liara **ست نکن** (فقط برای cert self-signed محیط dev بود).
|
||||||
|
- `NEXT_PUBLIC_CLIENT_SECRET` در باندل کلاینت قابلمشاهده است (طراحی فعلی پروژه همین است) — تغییرش خارج از این تسک.
|
||||||
|
|
||||||
|
### ۶. اتصال دامنههای چند-شهری
|
||||||
|
|
||||||
|
`getStateInfo.js` شهر را از هدر `Host` تشخیص میدهد. در کنسول Liara، **همهی دامنههای شهرها** (مثل `yazd-nobat.ir`، `tehran-nobat.ir`، `nobat724.com`، ...) را به همین یک اپ وصل کن و برای هرکدام TLS بگیر. نیازی به env جداگانه per-domain نیست — هدر `Host` خودکار شهر را تعیین میکند.
|
||||||
|
|
||||||
|
### ۷. (در صورت نیاز) افزودن دامنه backend به `images.remotePatterns`
|
||||||
|
|
||||||
|
اگر تصاویر از backend جدید (`clinicpro.liara.run` یا دامنهی API پروداکشن) با `next/image` لود میشوند، باید host آن در `next.config.js` → `images.remotePatterns` اضافه شود؛ وگرنه `next/image` آنها را بلاک میکند. host فعلی فقط `api.clinic-pro.ir`/`clinic-pro.ddev.site` و... را دارد.
|
||||||
|
|
||||||
|
```js
|
||||||
|
{ protocol: 'https', hostname: 'clinicpro.liara.run' },
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۸. دیپلوی
|
||||||
|
|
||||||
|
```bash
|
||||||
|
liara deploy --app nobat724 --platform nextjs --port 3000
|
||||||
|
```
|
||||||
|
(یا روی CI/کنسول). بعد از set کردن env جدید پس از دیپلوی، اپ را restart کن.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **حتماً `next.config.mjs` را حذف کن** — مهمترین ریسک؛ بدون آن `output: standalone` و CSP و images از کانفیگ واقعی اعمال نمیشوند.
|
||||||
|
- env های `NEXT_PUBLIC_*` **build-time** هستند: اگر بعد از build عوض شوند، تا **rebuild/redeploy** در باندل کلاینت اعمال نمیشوند (نه فقط restart).
|
||||||
|
- backend باید CORS سایت را اجازه دهد — دامنههای nobat724 در `clinicpro` (`ALLOWED_FRONTEND_HOSTS`/`CORS_ALLOW_ORIGIN`) از قبل لیست شدهاند؛ مطمئن شو دامنهای که روی Liara میسازی در آن لیست هست.
|
||||||
|
- `output: 'standalone'` با پلتفرم nextjs لیارا سازگار است و حجم/سرعت بهتر میدهد؛ نگهش دار.
|
||||||
|
- تست محلی قبل از دیپلوی: `npm run build` باید بدون خطا تمام شود (خطاهای صفحه/متادیتا اینجا ظاهر میشوند).
|
||||||
|
- این تغییرات infra هستند؛ منطق برنامه عوض نمیشود. فقط حذف فایل تکراری + ۳ فایل کانفیگ + env.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# بازگشت به صفحهٔ مبدأ بعد از لاگین (redirect-back)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی)
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
مکانیزم بازگشت بعد از لاگین **از قبل وجود دارد**: مرحلهٔ تأیید کد (`SendReq.js`) بعد از ورود موفق، پارامتر `?redirect=` را از URL میخواند و کاربر را به همان مسیر برمیگرداند (فقط مسیر داخلی امن). اما **لینکها/ناوبریهای لاگین در سایت این پارامتر را نمیسازند** — بهجز یک مورد (`components/doctor/claim/index.js`). نتیجه: کاربر از هر صفحهای (مثلاً صفحهٔ پزشک برای گرفتن نوبت) روی «ورود | ثبت نام» بزند، بعد از لاگین به `/` (خانه) میرود، نه صفحهٔ مبدأ.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
هر جای سایت که به `/login` میرویم، باید مسیر فعلی را بهصورت `?redirect=<current-path>` به لینک لاگین اضافه کنیم تا بعد از ورود موفق کاربر به همان صفحه برگردد. الگوی درست از قبل در پروژه هست و فقط باید به بقیهٔ نقاط تعمیم داده شود.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/register/verificationPage/SendReq.js` | (بدون تغییر) منطق بازگشت با `?redirect=` — L89–95 |
|
||||||
|
| `components/doctor/claim/index.js` | (مرجع الگوی درست) L191 — `/login?redirect=${encodeURIComponent(pathname)}` |
|
||||||
|
| `components/layout/header/Content.js` | لینک اصلی «ورود \| ثبت نام» هدر — L43 |
|
||||||
|
| `app/payment/[uuid]/page.js` | `router.push("/login")` هنگام نبود auth — L35 |
|
||||||
|
| `app/dashboard/page.js` | `redirect("/login")` سمت سرور برای صفحهٔ محافظتشده — L29 |
|
||||||
|
| `app/component/ModalLogout.js` | `router.replace("/login")` بعد از خروج — L19 (به «نکات» رجوع کن) |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
منطق بازگشت که **باید حفظ شود** (`SendReq.js` L89–95):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// بازگشت به صفحهٔ مبدأ اگر ?redirect= داده شده (فقط مسیر داخلی امن)
|
||||||
|
const redirect = new URLSearchParams(window.location.search).get("redirect");
|
||||||
|
window.location.href =
|
||||||
|
redirect && redirect.startsWith("/") && !redirect.startsWith("//")
|
||||||
|
? redirect
|
||||||
|
: "/";
|
||||||
|
```
|
||||||
|
|
||||||
|
الگوی درستِ موجود (`components/doctor/claim/index.js` L191):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Link href={`/login?redirect=${encodeURIComponent(pathname)}`} className="w-full">
|
||||||
|
```
|
||||||
|
|
||||||
|
لینک هدر که پارامتر ندارد (`components/layout/header/Content.js` L40–47) — این کامپوننت `"use client"` است و همین حالا `const pathname = usePathname();` را دارد (L17):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{isLogged ? (
|
||||||
|
<ProfileUser />
|
||||||
|
) : (
|
||||||
|
<Link href="/login">
|
||||||
|
<Button variant="outlined" color="primary">
|
||||||
|
ورود | ثبت نام
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. لینک لاگین هدر (اصلیترین)
|
||||||
|
|
||||||
|
در `components/layout/header/Content.js` L43، `pathname` که همینجا موجود است را به لینک اضافه کن. از رفتن به `/login?redirect=/login` جلوگیری کن:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
pathname && pathname !== "/login"
|
||||||
|
? `/login?redirect=${encodeURIComponent(pathname)}`
|
||||||
|
: "/login"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button variant="outlined" color="primary">
|
||||||
|
ورود | ثبت نام
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. ناوبری لاگین صفحهٔ پرداخت
|
||||||
|
|
||||||
|
در `app/payment/[uuid]/page.js` L35 (`router.push("/login")`)، مسیر فعلی را ضمیمه کن. این کامپوننت client است؛ اگر `usePathname` وارد نشده، آن را از `next/navigation` وارد کن و استفاده کن:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
// ...
|
||||||
|
const pathname = usePathname();
|
||||||
|
// ...
|
||||||
|
router.push(`/login?redirect=${encodeURIComponent(pathname)}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳. redirect سمت سرور داشبورد
|
||||||
|
|
||||||
|
در `app/dashboard/page.js` L29، صفحهٔ محافظتشده هنگام نبود auth به لاگین میرود؛ مقصد بازگشت را ثابت `/dashboard` بگذار:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
return redirect("/login?redirect=/dashboard");
|
||||||
|
```
|
||||||
|
|
||||||
|
(چون این redirect سمت سرور است و به مسیر ثابت داشبورد مربوط است، نیازی به `usePathname` نیست.)
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **پارامتر در طول جریان لاگین حفظ میشود:** صفحهٔ `/login` → `ContentLogin` → `RegisterPage` مرحلهٔ ارسال کد و تأیید را با state داخلی (`isSendMsg`) عوض میکند و **ناوبری جدید انجام نمیدهد**، پس URL روی `/login?redirect=...` میماند و `SendReq.js` با `window.location.search` آن را میخواند. نیازی به پاسدادن prop اضافه نیست.
|
||||||
|
- **امنیت redirect از قبل هندل شده:** فقط مسیرِ داخلی که با `/` شروع شود و با `//` شروع نشود پذیرفته میشود (`SendReq.js` L92). مقدار را همیشه با `encodeURIComponent(pathname)` بساز.
|
||||||
|
- **`app/login/page.js`**: کاربرِ از قبل لاگینشده به `/` هدایت میشود (`ability.can("access","Login")`). این رفتار درست است و نباید تغییر کند؛ فقط برای کاربرِ مهمان `?redirect=` معنا دارد.
|
||||||
|
- **`ModalLogout.js` (L19) را تغییر نده:** بعد از *خروج* بردن کاربر به `/login` با `?redirect=` به صفحهٔ محافظتشده باعث حلقه/بازگشت ناخواسته میشود. لاگاوت باید به `/login` سادهٔ فعلی برود.
|
||||||
|
- **صفحهٔ appointment inline login دارد:** جریان گرفتن نوبت با `setStep(3)` بعد از لاگین در همان صفحه میماند (`SendReq.js` L86–87) و ناوبری نمیکند؛ نیازی به `?redirect=` ندارد و نباید دست بخورد.
|
||||||
|
- `usePathname` فقط در Client Component کار میکند؛ برای redirectهای سمت سرور (وظیفهٔ ۳) از مسیر ثابت استفاده کن.
|
||||||
|
- بعد از تغییر: `npm run build` بدون خطا.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# نمایش حالت تعمیرات (Maintenance) در سایت عمومی
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
**cross-repo** — پرامپت همتا (که باید **اول** اجرا شود):
|
||||||
|
`clinicpro/.claude/prompt/maintenance-mode.md`
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
در backend یک Maintenance Mode مرکزی پیاده میشود: وقتی ادمین آن را روشن کند، هر درخواست به `/api/v1/...` (بهجز چند مسیر whitelistشده) با این پاسخ برمیگردد:
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP/1.1 503 Service Unavailable
|
||||||
|
Retry-After: 600
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"data": null,
|
||||||
|
"errors": [
|
||||||
|
{ "code": "MAINTENANCE_MODE", "message": "<پیام قابل ویرایش از پنل ادمین>" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
قرارداد تشخیص: **status = 503 و `errors[0].code === 'MAINTENANCE_MODE'`**. هر دو شرط باید چک شوند (503 خالی میتواند از nginx/load balancer هم بیاید).
|
||||||
|
|
||||||
|
بدون تغییر در این ریپو، رفتار فعلی این میشود:
|
||||||
|
|
||||||
|
- درخواستهای client-side: interceptor در [services/api.js:94](services/api.js#L94) فقط یک `toast.error` قرمز نشان میدهد و صفحه خالی/شکسته میماند
|
||||||
|
- درخواستهای server-side: `fetchReq` در [lib/req.js:16](lib/req.js#L16) روی هر خطا `null` برمیگرداند — صفحه بدون داده رندر میشود و **این حالت از یک صفحه واقعاً خالی قابل تفکیک نیست**
|
||||||
|
|
||||||
|
هدف: بهجای اینها یک صفحه تمیز maintenance با پیام آمده از backend.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `services/api.js` | axios instance + response interceptor (نقطه تشخیص client-side) |
|
||||||
|
| `lib/req.js` | `fetchReq()` server-side — الان status را دور میریزد |
|
||||||
|
| `app/maintenance/page.js` | **جدید** — صفحه تعمیرات |
|
||||||
|
| `components/maintenance/index.js` | **جدید** — UI کامل صفحه |
|
||||||
|
| `components/notFound/index.js` | الگوی طراحی full-page state که باید کپی شود |
|
||||||
|
| `app/layout.js` | `<CustomToastify />` در خط 140 |
|
||||||
|
| `app/error.js` | error boundary فعلی |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
`services/api.js:72-98`
|
||||||
|
|
||||||
|
```js
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response.data,
|
||||||
|
async (error) => {
|
||||||
|
const original = error?.config;
|
||||||
|
|
||||||
|
if (
|
||||||
|
error?.response?.status === 401 &&
|
||||||
|
original?.requireAuth &&
|
||||||
|
!original._retried &&
|
||||||
|
typeof window !== "undefined"
|
||||||
|
) {
|
||||||
|
original._retried = true;
|
||||||
|
const token = await refreshAccessToken();
|
||||||
|
if (token) {
|
||||||
|
original.headers = { ...original.headers, Authorization: `Bearer ${token}` };
|
||||||
|
return api(original);
|
||||||
|
}
|
||||||
|
handleSessionExpired();
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== "undefined" && !error?.config?.skipErrorToast) {
|
||||||
|
toast.error(extractErrorMessage(error));
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`lib/req.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const fetchReq = async (url, headers) => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get(url, headers);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("fetchReq error:", error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. helper مشترک تشخیص
|
||||||
|
|
||||||
|
فایل جدید `lib/maintenance.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const MAINTENANCE_CODE = "MAINTENANCE_MODE";
|
||||||
|
|
||||||
|
/** از یک خطای axios تشخیص میدهد که آیا پاسخ maintenance است */
|
||||||
|
export function isMaintenanceError(error) {
|
||||||
|
const res = error?.response;
|
||||||
|
if (res?.status !== 503) return false;
|
||||||
|
return res?.data?.errors?.[0]?.code === MAINTENANCE_CODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** از body پاسخ 503 پیام را استخراج میکند */
|
||||||
|
export function maintenanceMessage(data) {
|
||||||
|
return data?.errors?.[0]?.message
|
||||||
|
?? "سامانه موقتاً در دسترس نیست. لطفاً چند دقیقه دیگر مجدداً تلاش کنید.";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. تشخیص client-side در interceptor
|
||||||
|
|
||||||
|
در `services/api.js` **قبل از** بلاک 401 (چون 503 هرگز نباید مسیر refresh token را طی کند):
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (isMaintenanceError(error)) {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const msg = maintenanceMessage(error.response.data);
|
||||||
|
try { sessionStorage.setItem("maintenance_message", msg); } catch {}
|
||||||
|
if (window.location.pathname !== "/maintenance") {
|
||||||
|
window.location.replace("/maintenance");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
نکات:
|
||||||
|
|
||||||
|
- **حتماً قبل از بلاک 401** — وگرنه اگر مسیر auth هم روزی 503 بدهد، وارد حلقه refresh میشود.
|
||||||
|
- **هیچ toast نشان نده** برای maintenance؛ ریدایرکت جایگزین آن است. مطمئن شو بلاک `toast.error` پایین اجرا نمیشود (به خاطر `return` زودهنگام).
|
||||||
|
- `window.location.replace` عمداً استفاده شده نه `router.push` — چون interceptor خارج از React tree است و ریدایرکت باید کل state آلوده را دور بریزد. همچنین `replace` باعث میشود دکمه back کاربر را به صفحه شکسته برنگرداند.
|
||||||
|
- شرط `pathname !== "/maintenance"` الزامی است تا اگر خود صفحه maintenance درخواستی زد، حلقه ریدایرکت بینهایت نشود.
|
||||||
|
|
||||||
|
### ۳. تشخیص server-side در `fetchReq`
|
||||||
|
|
||||||
|
`lib/req.js` نباید روی 503 مثل بقیه خطاها `null` برگرداند. رفتار پیشنهادی: throw کردن یک خطای مشخص که در `app/error.js` قابل تشخیص باشد — یا (سادهتر و مطمئنتر) ریدایرکت مستقیم:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { isMaintenanceError, maintenanceMessage } from "@/lib/maintenance";
|
||||||
|
|
||||||
|
export const fetchReq = async (url, headers) => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get(url, headers);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (isMaintenanceError(error)) {
|
||||||
|
redirect("/maintenance");
|
||||||
|
}
|
||||||
|
console.error("fetchReq error:", error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**بحرانی:** `redirect()` در Next.js با پرتاب یک خطای خاص (`NEXT_REDIRECT`) کار میکند. اگر `fetchReq` داخل یک `try/catch` دیگر در صفحه صدا زده شود، آن catch خطای redirect را میبلعد و ریدایرکت انجام نمیشود. همهی call siteهای `fetchReq` را چک کن؛ هر جا داخل `try/catch` است، خطای `NEXT_REDIRECT` باید rethrow شود (`if (e?.digest?.startsWith("NEXT_REDIRECT")) throw e;`).
|
||||||
|
|
||||||
|
همچنین `redirect()` را نمیتوان داخل `generateMetadata` بهدرستی استفاده کرد — آنجا فقط بگذار `null` برگردد و صفحه خودش ریدایرکت کند.
|
||||||
|
|
||||||
|
### ۴. صفحه `/maintenance`
|
||||||
|
|
||||||
|
`app/maintenance/page.js`:
|
||||||
|
|
||||||
|
- Server Component ساده که `<MaintenancePage />` را رندر میکند
|
||||||
|
- `export const dynamic = "force-dynamic"` تا کش نشود
|
||||||
|
- `generateMetadata` صادر کند با `title` مناسب و **`robots: { index: false, follow: false }`** — صفحه تعمیرات نباید ایندکس شود
|
||||||
|
- بهتر: در همین صفحه یک بار سمت سرور `GET /api/v1/...` سبک بزن (مثلاً همان endpoint سلامت یا هر endpoint عمومی) تا اگر maintenance **تمام شده بود**، کاربر را به `/` برگرداند — وگرنه کاربری که این URL را باز نگه داشته برای همیشه صفحه تعمیرات میبیند
|
||||||
|
|
||||||
|
`components/maintenance/index.js`:
|
||||||
|
|
||||||
|
- طراحی را از `components/notFound/index.js` کپی کن — همان ساختار، همان MUI `Button`، همان breakpointهای Tailwind، همان لحن فارسی. **صفحه جدید با طراحی جدید نساز.**
|
||||||
|
- پیام: اول از `sessionStorage.getItem("maintenance_message")` (client) بخوان؛ اگر نبود متن پیشفرض فارسی
|
||||||
|
- دکمه «تلاش مجدد» که `window.location.href = "/"` میکند
|
||||||
|
- اختیاری: یک `setInterval` هر ۶۰ ثانیه که خودکار `/` را چک کند و اگر بالا آمد ریدایرکت کند
|
||||||
|
|
||||||
|
**نکته:** دکمه `components/notFound/index.js:28` در حال حاضر نه `onClick` دارد نه `href` — کنترل مرده است. آن باگ را کپی نکن.
|
||||||
|
|
||||||
|
### ۵. جلوگیری از حلقه
|
||||||
|
|
||||||
|
- صفحه `/maintenance` نباید هیچ درخواست `requireAuth` بزند
|
||||||
|
- اگر لایههای دیگری هم مستقیم fetch میزنند (`services/clinicApi.js` که native fetch است و روی `!response.ok` استثنا را میبلعد و آرایه خالی برمیگرداند)، آنها را هم برای 503 تطبیق بده — وگرنه صفحه کلینیک در حالت maintenance «۰ پزشک» نشان میدهد که گمراهکننده است
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- تشخیص **فقط** با ترکیب `503 + code === 'MAINTENANCE_MODE'`. صرفِ 503 کافی نیست.
|
||||||
|
- 503 هرگز نباید مسیر refresh token / `handleSessionExpired` را فعال کند — کاربر نباید در حالت تعمیرات logout شود.
|
||||||
|
- `robots: noindex` روی صفحه maintenance الزامی است.
|
||||||
|
- `CustomToastify` در [app/CustomToastify.js:6-14](app/CustomToastify.js#L6-L14) برای همهی toastها آیکون تیک ثابت دارد؛ به همین دلیل هم نمایش خطای maintenance با toast مناسب نیست.
|
||||||
|
- ترتیب اجرا: **اول** پرامپت backend (`clinicpro/.claude/prompt/maintenance-mode.md`)، بعد این. برای تست، maintenance را از پنل ادمین روشن کن و سایت عمومی را باز کن.
|
||||||
|
- تست دستی: (۱) صفحه اصلی (server-side render) (۲) صفحه پزشک `/doctor/[uuid]` (۳) یک اکشن client-side مثل جستجو (۴) خاموش کردن maintenance و اطمینان از برگشت خودکار سایت.
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# دامنهٔ ریشهٔ nobat724.com نباید مثل «شهر» رفتار کند
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی — Next.js 15، multi-domain)
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
هر شهر یک دامنهٔ اختصاصی دارد (`data/city.json`؛ مثل `yasuj-nobat.ir`) و `lib/getStateInfo.js` شهر را از روی subdomain تشخیص میدهد. اما `nobat724.com` یک رکورد **استثنا** در `city.json` است: `{ id: 600, name: "نوبت 724", domain: "nobat724.com", province_id: null }`. این دامنه **شهر نیست** — دامنهٔ ریشه/مخزنِ همهٔ شهرهاست.
|
||||||
|
|
||||||
|
الان بهخاطر این رکورد، وقتی `nobat724.com` باز میشود سیستم آن را مثل یک «شهر» با `city_id=600` میبیند:
|
||||||
|
- درخواست لیست پزشکان: `/api/v1/doctors?city_id=600&page=1&limit=12` (باید بدون `city_id` = سراسری باشد).
|
||||||
|
- هدر انتخاب شهر: `استان / نوبت 724` (باید placeholder `استان / شهر` باشد چون شهری انتخاب نشده).
|
||||||
|
|
||||||
|
علت فنی: در چند جا محافظ `matchedCity.id !== "600"` نوشته شده ولی `id` **عدد `600`** است و `"600"` **رشته** → `600 !== "600"` همیشه `true` → استثنا هرگز اعمال نمیشود.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
`nobat724.com` (id 600) بهعنوان **دامنهٔ ریشه** شناخته شود:
|
||||||
|
- هیچ `city_id`/`state_id` به API تزریق نشود (لیست پزشکان سراسری).
|
||||||
|
- شهر/استان بهصورت پیشفرض «انتخابنشده» باشد (هدر: `استان / شهر`).
|
||||||
|
- برندینگ (`site_name` = «نوبت 724»، slogan) حفظ شود.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `nobat724_front/lib/getStateInfo.js` | افزودن فلگ `isRoot` |
|
||||||
|
| `nobat724_front/app/doctors/page.js` | حذف تزریق city روی ریشه (باگ `!== "600"`) |
|
||||||
|
| `nobat724_front/components/doctors/index.js` | filter پیشفرض بدون شهر روی ریشه (باگ `!== "600"`) |
|
||||||
|
| `nobat724_front/context/ProvinceProvider.js` | `cityId=null` روی ریشه (client) |
|
||||||
|
| `nobat724_front/app/specialties/page.js` | `cityId` روی ریشه = null |
|
||||||
|
| (در صورت وجود) هر جای دیگری که `matchedCity.id` → `city_id` میشود |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
### `lib/getStateInfo.js`
|
||||||
|
```js
|
||||||
|
export async function getStateInfo() {
|
||||||
|
const headersList = await headers();
|
||||||
|
const host = headersList.get("host") || "";
|
||||||
|
const subdomain = host.split(".")[0];
|
||||||
|
const matchedCity = citiesData.find((city) => city.domain.split(".")[0] === subdomain);
|
||||||
|
const matchedState = matchedCity && statesData.find((s) => s.id === matchedCity.province_id);
|
||||||
|
return { matchedCity, matchedState };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `app/doctors/page.js` (باگ نوع)
|
||||||
|
```js
|
||||||
|
// Conditional City
|
||||||
|
if (cityParams) newSearchParams.city = cityParams;
|
||||||
|
else if (matchedCity && matchedCity.id !== "600") // ← 600 عدد است، "600" رشته → همیشه true
|
||||||
|
newSearchParams.city = matchedCity.name;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `components/doctors/index.js` (باگ نوع در defaultFilter)
|
||||||
|
```js
|
||||||
|
const defaultCity =
|
||||||
|
(matchedCity?.name && matchedCity?.id !== "600" // ← همان باگ
|
||||||
|
? matchedCity?.name
|
||||||
|
: "") /* ... */;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `context/ProvinceProvider.js`
|
||||||
|
```js
|
||||||
|
const isProvinceInclude = citiesData.find((city) => hostName.includes(city.domain.split(".")[0]));
|
||||||
|
// ...
|
||||||
|
<ProvinceContext.Provider value={{ isProvinceInclude, cityId: isProvinceInclude?.id ?? null }}>
|
||||||
|
// روی nobat724.com → cityId = 600
|
||||||
|
```
|
||||||
|
|
||||||
|
### `app/specialties/page.js`
|
||||||
|
```js
|
||||||
|
<... cityId={matchedCity?.id ?? null} ... /> // روی ریشه → 600
|
||||||
|
```
|
||||||
|
|
||||||
|
### `services/response.js` (بدون تغییر — فقط برای مرجع)
|
||||||
|
```js
|
||||||
|
getDoctors: (params) => api.get("api/v1/doctors", { params, ... }),
|
||||||
|
getSpecialtyDoctorCounts: (cityId) =>
|
||||||
|
api.get("api/v1/specialties/doctor-counts", { params: cityId ? { city_id: cityId } : {}, ... }),
|
||||||
|
```
|
||||||
|
`helper/index.js` → `buildDoctorParams` فقط وقتی `searchParams.city` مقدار دارد `city_id` میسازد؛ پس اگر روی ریشه city ست نشود، `city_id` هم نمیرود.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. تعریف مرکزی ریشه + `isRoot` در `getStateInfo`
|
||||||
|
|
||||||
|
یک ثابت مشترک بساز و در `getStateInfo` فلگ برگردان:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// lib/getStateInfo.js
|
||||||
|
export const ROOT_CITY_ID = 600; // رکورد nobat724.com در city.json (شهر نیست)
|
||||||
|
export const isRootCity = (c) => c != null && Number(c.id) === ROOT_CITY_ID;
|
||||||
|
|
||||||
|
export async function getStateInfo() {
|
||||||
|
// ... مثل قبل ...
|
||||||
|
const isRoot = isRootCity(matchedCity);
|
||||||
|
return { matchedCity, matchedState, isRoot };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `matchedCity` را برای برندینگ (site_name/slogan/description) نگه دار؛ فقط منطقِ **فیلتر شهر** باید روی ریشه غیرفعال شود.
|
||||||
|
|
||||||
|
### ۲. `app/doctors/page.js` — روی ریشه city تزریق نشود
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { matchedCity, matchedState, isRoot } = await getStateInfo();
|
||||||
|
// ...
|
||||||
|
// Conditional City
|
||||||
|
if (cityParams) newSearchParams.city = cityParams;
|
||||||
|
else if (matchedCity && !isRoot) newSearchParams.city = matchedCity.name;
|
||||||
|
```
|
||||||
|
(اگر `matchedState` روی ریشه هم null است مشکلی نیست؛ چون province_id=null. ولی برای اطمینان میتوانی `else if (matchedState && !isRoot)` بگذاری.)
|
||||||
|
|
||||||
|
### ۳. `components/doctors/index.js` — defaultFilter بدون شهر روی ریشه
|
||||||
|
|
||||||
|
`isRoot` را از صفحهٔ سرور بهعنوان prop بده یا محلی محاسبه کن (`Number(matchedCity?.id) === 600`)، و در `defaultCity`/`defaultFilter` استفاده کن:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const isRoot = Number(matchedCity?.id) === 600;
|
||||||
|
const defaultCity = (matchedCity?.name && !isRoot) ? matchedCity.name : "";
|
||||||
|
const defaultState = (matchedState?.name && !isRoot) ? matchedState.name : "";
|
||||||
|
```
|
||||||
|
با این کار `filter.city`/`filter.state` روی ریشه خالی میمانند و `FilterLocate` هدر را `استان / شهر` نشان میدهد (نه «نوبت 724»).
|
||||||
|
|
||||||
|
### ۴. `context/ProvinceProvider.js` — cityId روی ریشه null
|
||||||
|
|
||||||
|
```js
|
||||||
|
const isRoot = Number(isProvinceInclude?.id) === 600;
|
||||||
|
const cityId = isRoot ? null : (isProvinceInclude?.id ?? null);
|
||||||
|
// اگر مصرفکنندهها به isProvinceInclude برای «شهر فعلی» تکیه دارند، روی ریشه آن را هم null بده:
|
||||||
|
const currentCity = isRoot ? null : isProvinceInclude;
|
||||||
|
// value={{ isProvinceInclude: currentCity, cityId }}
|
||||||
|
```
|
||||||
|
(بررسی کن مصرفکنندههای `useProvince()` با `null` درست کار کنند — یعنی «شهری انتخاب نشده».)
|
||||||
|
|
||||||
|
### ۵. `app/specialties/page.js` (و هر `getSpecialtyDoctorCounts`/شمارش مبتنی بر شهر)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { matchedCity, matchedState, isRoot } = await getStateInfo();
|
||||||
|
// ...
|
||||||
|
cityId={isRoot ? null : (matchedCity?.id ?? null)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۶. جستجوی باقیماندهٔ الگوی باگ
|
||||||
|
|
||||||
|
کل `nobat724_front` را برای `!== "600"` / `=== "600"` / `id === 600`/`city_id` مبتنی بر `matchedCity`/`isProvinceInclude` بگرد و همه را با `isRoot`/`ROOT_CITY_ID` یکدست کن:
|
||||||
|
```bash
|
||||||
|
grep -rn '"600"\|=== 600\|!== 600\|matchedCity?.id\|isProvinceInclude?.id' app components context lib services helper
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **type-safety:** id در `city.json` عدد است؛ هرگز با رشتهٔ `"600"` مقایسه نکن. از `Number(x) === ROOT_CITY_ID` یا `isRootCity()` استفاده کن.
|
||||||
|
- **برندینگ حفظ شود:** روی ریشه، `matchedCity.site_name`/`slogan`/عنوان متا («نوبت 724») درست است؛ فقط فیلتر شهر و هدر انتخابشهر نباید ۶۰۰ را بهعنوان شهرِ انتخابشده نشان دهند.
|
||||||
|
- **canonical:** در `app/layout.js` از قبل `matchedCity.domain !== 'nobat724.com'` هست؛ دست نزن، فقط اگر خواستی با `isRoot` یکدست کن.
|
||||||
|
- **رفتار موردانتظار روی nobat724.com:** لیست پزشکان بدون `city_id` (سراسری)، هدر `استان / شهر`، انتخاب شهر توسط کاربر باعث ستشدن فیلتر و رفتن `city_id` واقعی میشود.
|
||||||
|
- **رفتار دامنههای شهری تغییر نکند:** روی `yasuj-nobat.ir` و بقیه باید دقیقاً مثل قبل `city_id` شهر برود.
|
||||||
|
- backend تغییری ندارد؛ فقط رفتار کلاینت. بعد از تغییر: `npm run build` و `npm run lint` بدون خطا؛ سپس با host `nobat724.com` (یا mock header) بررسی کن که `city_id` نمیرود و هدر `استان / شهر` است، و با یک دامنهٔ شهری بررسی کن `city_id` همچنان میرود.
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# اصلاحات Nobat724: رفع خطای جستجوی پزشک + بهینهسازی SEO صفحات لیست و تخصص
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
> این پرامپت جایگزین `fix-search-autocomplete-doctor-name.md` است (تسک ۱ همان باگ را پوشش میدهد) و چهار تسک SEO را هم اضافه میکند. هر تسک مستقل است؛ میتوانی جدا کامیت کنی.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
سایت عمومی نوبتدهی چند-دامنهای (هر شهر یک دامنه، مثل `yasuj-nobat.ir`، و دامنهٔ ریشه `nobat724.com`). پنج اصلاح لازم است: یک باگ جستجوی UI و چهار مورد بهینهسازی SEO روی صفحات لیست پزشکان و صفحات فرود تخصص.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## تسک ۱ — رفع خطای `getOptionLabel` در جستجوی نام پزشک
|
||||||
|
|
||||||
|
### مشکل
|
||||||
|
|
||||||
|
در صفحهٔ اصلی، تایپ «دکتر مهدیه» در باکس جستجو این ارور را میدهد:
|
||||||
|
|
||||||
|
```
|
||||||
|
MUI: The `getOptionLabel` method of Autocomplete returned undefined instead of a string for "دکتر مهدیه".
|
||||||
|
```
|
||||||
|
|
||||||
|
علت: `Autocomplete` با `freeSolo` رندر شده و `options={specialtiesData}` (اشیاء تخصص با فیلد `name`). وقتی کاربر متنی تایپ میکند که در options نیست، MUI مقدار تایپشده را بهصورت **رشته** به `getOptionLabel` میدهد؛ `"دکتر مهدیه".name` میشود `undefined`. دیتای `specialties.json` سالم است (هر ۹۳ آیتم `name` دارند) — منشأ ارور رشتهٔ freeSolo است، نه آیتم بدون name.
|
||||||
|
|
||||||
|
### فایل
|
||||||
|
|
||||||
|
`components/searchHead/smSearch/AutoCompleteSearch.js`
|
||||||
|
|
||||||
|
### وضعیت فعلی
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
getOptionLabel={(option) => option.name} // خط ۲۷ — روی رشته میشکند
|
||||||
|
...
|
||||||
|
onChange={(_, newValue) => {
|
||||||
|
setSelectedOption && setSelectedOption(newValue);
|
||||||
|
handleSearch(newValue ? newValue.name : ""); // خط ۳۶ — newValue میتواند رشته باشد
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### راهحل
|
||||||
|
|
||||||
|
هر دو نقطه را برای رشتهٔ freeSolo امن کن؛ همیشه string برگردان:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
getOptionLabel={(option) =>
|
||||||
|
typeof option === "string" ? option : option?.name ?? ""
|
||||||
|
}
|
||||||
|
...
|
||||||
|
onChange={(_, newValue) => {
|
||||||
|
setSelectedOption && setSelectedOption(newValue);
|
||||||
|
const label =
|
||||||
|
typeof newValue === "string" ? newValue : newValue?.name ?? "";
|
||||||
|
handleSearch(label);
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
`renderOption` (خط ۳۹–۴۳) فقط برای options واقعی صدا زده میشود؛ دست نزن.
|
||||||
|
|
||||||
|
### تست
|
||||||
|
|
||||||
|
- تایپ «دکتر مهدیه» → بدون ارور؛ کلیک جستجو → هدایت به `/doctors?...name=دکتر مهدیه` (منطق redirect در `components/home/search/RedirectLink.js` از قبل درست است: match نشدن با تخصص → `filters.name`).
|
||||||
|
- انتخاب یک تخصص از لیست (مثل «داخلی») → `/doctors?...specialty=داخلی`.
|
||||||
|
- خالیکردن ورودی → بدون ارور.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## تسک ۲ — `noindex, follow` برای همهٔ صفحات جستجوی `/doctors?...`
|
||||||
|
|
||||||
|
### هدف
|
||||||
|
|
||||||
|
هر `/doctors` که **کوئریپارامتر داشته باشد** (`?specialty=`, `?city=`, `?state=`, `?name=`, یا هر ترکیبی) باید `robots: noindex, follow` بگیرد. فقط `/doctors` بدونِ کوئری باید ایندکس شود. دلیل: صفحات جستجو داینامیک و Duplicate/Thin هستند؛ صفحهٔ فرود قابلایندکسِ تخصص باید `/specialties/[slug]` باشد (تسک ۳)، نه `/doctors?specialty=`.
|
||||||
|
|
||||||
|
### فایل
|
||||||
|
|
||||||
|
`lib/listingRobots.js` (توسط `app/doctors/page.js` خط ۲۵ و صفحهٔ `/clinics` مصرف میشود).
|
||||||
|
|
||||||
|
### وضعیت فعلی
|
||||||
|
|
||||||
|
```js
|
||||||
|
const USER_FILTER_KEYS = ["specialty", "gender", "degree", "active", "sort"];
|
||||||
|
|
||||||
|
export function listingRobots(params, { matchedCity, matchedState } = {}) {
|
||||||
|
if (params?.name) return { robots: { index: false, follow: true } };
|
||||||
|
const activeFilters = USER_FILTER_KEYS.filter((key) => params?.[key]);
|
||||||
|
if (params?.city && params.city !== matchedCity?.name) activeFilters.push("city");
|
||||||
|
if (params?.state && params.state !== matchedState?.name) activeFilters.push("state");
|
||||||
|
if (activeFilters.length >= 2) return { robots: { index: false, follow: true } };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
منطق فعلی عمداً «تکفیلتر specialty» و «city/state تزریقشده از دامنه» را ایندکسپذیر نگه میداشت. طبق خواستهٔ جدید این رفتار باید عوض شود: **هر کوئریپارامتر واقعی → noindex**.
|
||||||
|
|
||||||
|
### راهحل
|
||||||
|
|
||||||
|
منطق را ساده و سختگیرانه کن: اگر بعد از حذف کلیدهای خالی، هیچ پارامتری نمانده → `{}` (ایندکس)؛ در غیر این صورت → `noindex, follow`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// هر جستجوی /doctors با کوئری واقعی noindex,follow میشود؛ فقط /doctors خام ایندکس.
|
||||||
|
export function listingRobots(params) {
|
||||||
|
const hasQuery =
|
||||||
|
params &&
|
||||||
|
Object.entries(params).some(
|
||||||
|
([, v]) => v !== undefined && v !== null && v !== ""
|
||||||
|
);
|
||||||
|
return hasQuery ? { robots: { index: false, follow: true } } : {};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### نکتهٔ حیاتی — مرز URL vs. فیلترِ سرور
|
||||||
|
|
||||||
|
در `app/doctors/page.js` روی دامنهٔ شهری، `city`/`state` **در URL نیستند**؛ سرور آنها را فقط داخل fetch تزریق میکند (`newSearchParams`)، ولی `generateMetadata` تابع را با `awaitedParams = await searchParams` (پارامترهای واقعی URL) صدا میزند. پس `/doctors` خام روی `yasuj-nobat.ir` همچنان بدون کوئری و **ایندکسپذیر** میماند. ✅ قبل از پیادهسازی این را در همان فایل تأیید کن (خط ۹–۲۵) که `listingRobots` با پارامترهای خام URL صدا زده میشود، نه با `newSearchParams`.
|
||||||
|
|
||||||
|
بعد از تغییر امضا (حذف آرگومان دوم)، فراخوان در `app/doctors/page.js` و هر مصرفکنندهٔ دیگر (`/clinics`) را همراستا کن — `grep -rn "listingRobots" app` بگیر و همه را بررسی کن.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## تسک ۳ — قابلایندکسبودن `/specialties` و صفحات تخصص
|
||||||
|
|
||||||
|
### وضعیت فعلی (اکثراً درست)
|
||||||
|
|
||||||
|
- `app/specialties/page.js` — هیچ override روباتی ندارد → ایندکسپذیر. ✅ نیازی به تغییر نیست.
|
||||||
|
- `app/specialties/[slug]/page.js` — فقط وقتی `total === 0` است `noindex,follow` میدهد (thin content)، وگرنه ایندکس. ✅
|
||||||
|
|
||||||
|
فقط قانون تسک ۴ باید به `[slug]` اضافه شود. کاری اضافه در این تسک لازم نیست جز اطمینان از اینکه بعد از تسک ۲، صفحات `/specialties/[child-slug]` (مثل `endourology`, `gastroenterology`) ایندکسپذیر میمانند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## تسک ۴ — فقط تخصصهای **فرزند** ایندکس شوند (والدها noindex)
|
||||||
|
|
||||||
|
### هدف
|
||||||
|
|
||||||
|
تخصصهای والد (`parent_id === null`، مثل «داخلی») صفحاتشان دستهبندی کلی و کممحتوا است و با فرزندان در نتایج گوگل رقابت میکنند. باید:
|
||||||
|
|
||||||
|
- `/specialties/internal-medicine` (والد، `parent_id: null`) → `noindex, follow`
|
||||||
|
- `/specialties/gastroenterology` (فرزند، `parent_id: 2`) → `index`
|
||||||
|
|
||||||
|
### فایل
|
||||||
|
|
||||||
|
`app/specialties/[slug]/page.js` — تابع `generateMetadata` (خط ۴۳–۶۹).
|
||||||
|
|
||||||
|
### وضعیت فعلی
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { total } = await getDoctors(specialty.id, isRoot ? null : matchedCity?.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
...(total === 0 && { robots: { index: false, follow: true } }),
|
||||||
|
openGraph: { title, description, images: [image] },
|
||||||
|
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`specialty` از `findSpecialty(slug)` میآید و شیء کامل `specialties.json` است، پس `specialty.parent_id` در دسترس است.
|
||||||
|
|
||||||
|
### راهحل
|
||||||
|
|
||||||
|
والد (`parent_id === null`) را هم به شرط noindex اضافه کن. توجه: `parent_id` ممکن است `null` باشد — از مقایسهٔ صریح استفاده کن.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const isParentCategory = specialty.parent_id == null; // والد یا بدون parent
|
||||||
|
const noindex = isParentCategory || total === 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
...(noindex && { robots: { index: false, follow: true } }),
|
||||||
|
openGraph: { title, description, images: [image] },
|
||||||
|
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### نکته
|
||||||
|
|
||||||
|
- اگر `sitemap.js` صفحات تخصص را لیست میکند، مطمئن شو والدها آنجا هم حذف/فیلتر میشوند تا با `noindex` همخوان باشد. `grep -rn "specialties" app/sitemap.js` و در صورت وجود، والدها (`parent_id == null`) را از خروجی sitemap کنار بگذار.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## تسک ۵ — اصلاح نگارشی متن SEO سوالات متداول تخصص + برند روی دامنهٔ ریشه
|
||||||
|
|
||||||
|
### فایل
|
||||||
|
|
||||||
|
`lib/specialtyContent.js` — تابع `buildSpecialtyFaq` (خط ۹۸–۱۲۰). مصرفکننده: `app/specialties/[slug]/page.js` خط ۸۶.
|
||||||
|
|
||||||
|
### وضعیت فعلی
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function buildSpecialtyFaq(specialtyName, cityName, doctorCount = 0) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
question: `چطور از متخصص ${specialtyName} در ${cityName} نوبت بگیرم؟`,
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
روی دامنهٔ ریشه `cityName` برابر `"ایران"` است (از `resolveCityDisplayName` → `ROOT_DISPLAY_NAME`)، پس سوال میشود: «چطور از متخصص اینترونشنال کاردیولوژی در **ایران** نوبت بگیرم؟».
|
||||||
|
|
||||||
|
### خواسته
|
||||||
|
|
||||||
|
۱. جملهٔ سوال به شکل نگارشیِ روانتر تغییر کند: «چطور از … نوبت **بگیرم؟**» → «چطور **میتوان** از … نوبت **گرفت؟**».
|
||||||
|
۲. روی دامنهٔ ریشه، بهجای «ایران» برند «**نوبت724**» استفاده شود (تقویت برند + طبیعیتر). روی دامنهٔ شهری همان نام شهر بماند.
|
||||||
|
|
||||||
|
### راهحل
|
||||||
|
|
||||||
|
بدون تغییر `ROOT_DISPLAY_NAME` سراسری (که در Titleها استفاده میشود و «متخصص X در ایران» آنجا درست است)، فقط داخل FAQ برند را جایگزین کن. یک آرگومان اختیاری `isRoot` به تابع اضافه کن:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function buildSpecialtyFaq(specialtyName, cityName, doctorCount = 0, { isRoot = false } = {}) {
|
||||||
|
const loc = isRoot ? "نوبت724" : cityName;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
question: `چطور میتوان از متخصص ${specialtyName} در ${loc} نوبت گرفت؟`,
|
||||||
|
answer: `از فهرست همین صفحه پزشک موردنظرتان را انتخاب کنید، وارد پروفایل او شوید و از تقویم نوبتهای خالی، تاریخ و ساعت دلخواه را ثبت کنید. رزرو در همان لحظه قطعی میشود و تأییدیه برایتان پیامک میشود.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: `چند پزشک ${specialtyName} در ${loc} در دسترس است؟`,
|
||||||
|
answer:
|
||||||
|
doctorCount > 0
|
||||||
|
? `در حال حاضر ${doctorCount} پزشک ${specialtyName} در ${loc} در این سامانه ثبت شدهاند. این عدد با افزودهشدن پزشکان جدید بهروزرسانی میشود.`
|
||||||
|
: `فهرست پزشکان ${specialtyName} در ${loc} در حال تکمیل است و بهمرور پزشکان جدید به آن افزوده میشوند.`,
|
||||||
|
},
|
||||||
|
// بقیهٔ سوالات (هزینه ویزیت / لغو نوبت) بدون تغییر
|
||||||
|
...
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
سپس در `app/specialties/[slug]/page.js` خط ۸۶، `isRoot` را پاس بده:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const faq = buildSpecialtyFaq(specialty.name, cityName, total, { isRoot });
|
||||||
|
```
|
||||||
|
|
||||||
|
(`isRoot` از قبل در همان تابع از `getStateInfo()` گرفته شده — خط ۷۶.)
|
||||||
|
|
||||||
|
### نکات
|
||||||
|
|
||||||
|
- **فقط FAQ** برند را عوض کند؛ `buildSpecialtyIntro` و Titleها دستنخورده بمانند (روی ریشه همچنان «در ایران»). این عمدی است تا فقط متن سوالِ موردِ اشارهٔ کاربر اصلاح شود.
|
||||||
|
- این متن هم در بلوک بصری صفحه و هم در `FAQPage` JSON-LD استفاده میشود (منبع مشترک) — پس اصلاح یکجا هر دو را میپوشاند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکات کلی پروژه
|
||||||
|
|
||||||
|
- App Router / Next.js 15؛ همیشه `await params` و `await searchParams`.
|
||||||
|
- بعد از تغییرِ `listingRobots`، `robots.js`/`sitemap.js` را برای ناسازگاری بررسی کن (صفحات noindex نباید در sitemap باشند).
|
||||||
|
- RTL فارسی، MUI v5 + Tailwind، فونت Vazir؛ استایل جدید اضافه نکن.
|
||||||
|
- بعد از اتمام: `graphify update .` برای بهروز نگهداشتن گراف (بعد از کامیت).
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
# راهاندازی تست و نوشتن تستسوئیت برای سایت عمومی (nobat724_front)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — سایت عمومی نوبتدهی (Next.js 15 App Router، React 18، MUI v5 + Tailwind، RTL فارسی، Jalali، چند-دامنهای از روی subdomain). پروژهی Node خالص (`npm run ...`)، **JavaScript/JSX خالص — بدون TypeScript**.
|
||||||
|
|
||||||
|
> این پرامپت معادلِ `clinicpro/.claude/prompt/admin-spa-test-suite.md` است اما برای سایت عمومی. تفاوتهای مهم: JS بهجای TS، Next.js App Router بهجای SPA، نیاز به mock کردن `next/headers`/`next/navigation`/`window.location`، و منطق تشخیص شهر از subdomain.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
سایت عمومی **هیچ زیرساخت تستی ندارد** — `package.json` فقط `cross-env` و `prettier` بهعنوان devDep دارد و هیچ script `test`. در عین حال پر از منطق خالص و قابلتست است: نرمالسازی موبایل/کدملی، تبدیل رقم فارسی، query-builderهای فیلتر پزشک/کلینیک، تشخیص شهر از host، CASL ability، آداپتر اسلات نوبت، و wrapperهای سرویس API. هیچکدام پوشش ندارند.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
۱. **Vitest + Testing Library + jsdom** را برای یک پروژهی Next.js 15 App Router با **JS/JSX** از صفر راهاندازی کن (مستقل از Next build).
|
||||||
|
۲. تستها را **لایهبهلایه** بنویس: توابع خالص (`utils/`, `lib/`) → تشخیص چند-دامنهای → سرویسها → CASL/context → چند کامپوننت. الگوی تکرارپذیر برای بقیه مستقر کن.
|
||||||
|
۳. هدف: پوشش معنادار منطق، نه عدد صوری.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش | تست هدف |
|
||||||
|
|------|-----|---------|
|
||||||
|
| `package.json` | scripts + deps | افزودن deps تست + script `test` |
|
||||||
|
| `jsconfig.json` | alias `@/* → ./*` | باید در vitest منعکس شود |
|
||||||
|
| `vitest.config.mjs` | **ساخته شود** | کانفیگ runner |
|
||||||
|
| `test/setup.js` | **ساخته شود** | jest-dom + mockهای سراسری |
|
||||||
|
| `test/utils.jsx` | **ساخته شود** | `renderWithProviders` |
|
||||||
|
| `utils/index.js` | توابع خالص پرشمار | تست واحد سنگین |
|
||||||
|
| `lib/appointmentSlots.js` | `adaptSlots`, `hasAvailable` | خالص |
|
||||||
|
| `lib/tokenStore.js` | توکن in-memory | خالص |
|
||||||
|
| `lib/sanitize.js` | `sanitizeHtml`, `safeJsonParse` | خالص |
|
||||||
|
| `lib/refreshCookie.js` | `cookieDomain(host)` | خالص |
|
||||||
|
| `lib/ability.js` | `defineAbilitiesFor(user)` (CASL) | خالص |
|
||||||
|
| `lib/representationAdapters.js` | `buildPatientUser` | خالص |
|
||||||
|
| `lib/getStateInfo.js` | تشخیص شهر سرور (`next/headers`) | mock headers |
|
||||||
|
| `lib/getStateInfoClient.js` | تشخیص شهر کلاینت (`window`) | mock window |
|
||||||
|
| `lib/getCanonicalUrl.js` | `getCanonicalUrlClient()` | خالص/جزئی |
|
||||||
|
| `services/clinicApi.js` | `getClinicDoctors` (native fetch) | mock fetch |
|
||||||
|
| `services/response.js` | wrapperهای `request.*` | mock `@/services/api` |
|
||||||
|
| `context/ProvinceProvider.js` | `useProvince()` | render + mock window |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
`package.json` (بدون test، فقط دو devDep):
|
||||||
|
```json
|
||||||
|
"scripts": {
|
||||||
|
"dev": "cross-env HOST=yazd-nobat.localhost PORT=3000 NODE_TLS_REJECT_UNAUTHORIZED=0 next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"devDependencies": { "cross-env": "^10.1.0", "prettier": "^3.7.4" }
|
||||||
|
```
|
||||||
|
نسخهها: `next ^15.5.7`، `react ^18.3.1` (نه ۱۹)، `@mui/material ^5`، `@casl/ability ^6`، `axios ^1`، `moment-jalaali`/`dayjs`. alias در `jsconfig.json`: `"paths": { "@/*": ["./*"] }` (ریشهی پروژه).
|
||||||
|
|
||||||
|
`lib/appointmentSlots.js` (خالص — هدف عالی):
|
||||||
|
```js
|
||||||
|
// adaptSlots(slotsResponse): اسلاتها را به { morning, evening } تقسیم میکند
|
||||||
|
// بر اساس slot.start_time < "12:00" ؛ ورودی میتواند data.sessions یا sessions باشد
|
||||||
|
// hasAvailable(slots): Array.isArray(slots) && slots.some(s => s.is_available)
|
||||||
|
```
|
||||||
|
|
||||||
|
`utils/index.js` — اهداف خالص (امضاهای واقعی):
|
||||||
|
```js
|
||||||
|
formatPhoneNumber(input) // رقم فارسی/عربی → ASCII، حذف غیررقم، حداکثر ۹، فرمت "## ### ####"
|
||||||
|
validatePhoneNumber(input) // { valid, text } ؛ قانون: ۱۱ رقم، ^09\d{9}$ ؛ پیامها فارسی
|
||||||
|
isValidIranNationalCode(input) // checksum کدملی ایران (۱۰ رقم، رد ارقام یکسان، mod-11)
|
||||||
|
getCleanNumberValue(value, def=0)// رقم فارسی/عربی→انگلیسی، strip به [0-9.]، parseFloat، NaN→def
|
||||||
|
changeNumToDefault(str) // ۰-۹ و ٠-٩ → ASCII
|
||||||
|
imageUrl(url, fallback) // URL مطلق یا /assets عبور میکند؛ /uploads → پیشوند NEXT_PUBLIC_API_URL
|
||||||
|
normalizeBlog(blog) // reshape به { images, tag, created, body, author }
|
||||||
|
// Query builderها (خالص؛ نگاشت فیلتر UI → پارامتر API):
|
||||||
|
// QueryForDoctorsFilter, QueryForDoctorsReq, buildDoctorParams,
|
||||||
|
// QueryForClinicsFilter, buildClinicParams
|
||||||
|
// نگاشتها: gender 'مرد'→'man'/'زن'→'woman' ؛ sort '3'→'ASC'/'4'|'5'→'DESC' ؛ limit:12
|
||||||
|
```
|
||||||
|
|
||||||
|
`services/clinicApi.js` (native fetch، double-nested):
|
||||||
|
```js
|
||||||
|
// getClinicDoctors(slug, params={}):
|
||||||
|
// fetch(`${NEXT_PUBLIC_API_URL}/api/v1/clinic/doctor-list/${slug}?${URLSearchParams}`)
|
||||||
|
// throw اگر baseUrl یا slug نباشد
|
||||||
|
// موفق → unwrap json.data.data و json.data.meta → { data, page:{ total_pages, current } }
|
||||||
|
// خطا → { data: [], page: { total_pages: 1, current: 1 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
`lib/getStateInfoClient.js` (sync، SSR-guard):
|
||||||
|
```js
|
||||||
|
// typeof window === "undefined" → { matchedCity:null, matchedState:null, host:null, fullUrl:null }
|
||||||
|
// subdomain = window.location.host.split(".")[0]
|
||||||
|
// matchedCity = city.json.find(c => c.domain.includes(subdomain))
|
||||||
|
// matchedState = state.json.find(s => s.id === matchedCity.province_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
`lib/ability.js` (CASL):
|
||||||
|
```js
|
||||||
|
// defineAbilitiesFor(user): اگر user → can('access','Dashboard'), cannot('access','Login')
|
||||||
|
// وگرنه → can('access','Login'), cannot('access','Dashboard')
|
||||||
|
// return build()
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. نصب ابزار و کانفیگ runner
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i -D vitest@^2 jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event @vitejs/plugin-react@^4
|
||||||
|
```
|
||||||
|
> **مهم:** `@vitejs/plugin-react` را روی **v4** پین کن — v6 فقط-ESM است و با vite 5 (که vitest 2 استفاده میکند) ناسازگار است و runner را میشکند.
|
||||||
|
|
||||||
|
`nobat724_front/vitest.config.mjs` بساز (پسوند `.mjs` چون پروژه `"type":"module"` ندارد و config باید ESM لود شود):
|
||||||
|
```js
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// include: /\.(js|jsx)$/ → JSX داخل فایلهای .js را هم transform کن (Next از .js استفاده میکند)
|
||||||
|
plugins: [react({ include: /\.(js|jsx)$/ })],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': fileURLToPath(new URL('.', import.meta.url)) },
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./test/setup.js'],
|
||||||
|
include: ['**/*.{test,spec}.{js,jsx}'],
|
||||||
|
exclude: ['node_modules', '.next', 'e2e'],
|
||||||
|
css: false,
|
||||||
|
clearMocks: true,
|
||||||
|
restoreMocks: true,
|
||||||
|
env: { NEXT_PUBLIC_API_URL: 'http://api.test.local' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
> `env.NEXT_PUBLIC_API_URL` لازم است چون `services/api.js` و `services/clinicApi.js` هنگام import آن را میخوانند.
|
||||||
|
|
||||||
|
scriptها در `package.json`:
|
||||||
|
```json
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:cov": "vitest run --coverage"
|
||||||
|
```
|
||||||
|
|
||||||
|
`test/setup.js`:
|
||||||
|
```js
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import { afterEach, vi } from 'vitest';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
window.matchMedia = window.matchMedia || ((q) => ({
|
||||||
|
matches: false, media: q, onchange: null,
|
||||||
|
addEventListener: vi.fn(), removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
`test/utils.jsx` (برای کامپوننتهایی که نیاز به provider/router دارند):
|
||||||
|
```jsx
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
|
||||||
|
// next/navigation در jsdom وجود ندارد — هر تست کامپوننت که از useRouter/usePathname
|
||||||
|
// استفاده میکند باید آن را mock کند (پایین). این helper فقط render پایه است.
|
||||||
|
export function renderUI(ui, options) {
|
||||||
|
return render(ui, options);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. تست توابع خالص `utils/index.js` (اول و سنگینترین)
|
||||||
|
|
||||||
|
`utils/index.test.js` — همهی توابع خالص را پوشش بده. نمونهها:
|
||||||
|
```js
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
changeNumToDefault, formatPhoneNumber, validatePhoneNumber,
|
||||||
|
isValidIranNationalCode, getCleanNumberValue, imageUrl,
|
||||||
|
} from '@/utils';
|
||||||
|
|
||||||
|
describe('changeNumToDefault', () => {
|
||||||
|
it('رقم فارسی و عربی → انگلیسی', () => {
|
||||||
|
expect(changeNumToDefault('۰۹۱۲')).toBe('0912');
|
||||||
|
expect(changeNumToDefault('٠٩١٢')).toBe('0912');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validatePhoneNumber', () => {
|
||||||
|
it.each([
|
||||||
|
['09123456789', true],
|
||||||
|
['9123456789', false],
|
||||||
|
['0812345678', false],
|
||||||
|
])('%s → valid=%s', (input, valid) => {
|
||||||
|
expect(validatePhoneNumber(input).valid).toBe(valid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidIranNationalCode', () => {
|
||||||
|
it('کدملی معتبر را میپذیرد و نامعتبر را رد میکند', () => {
|
||||||
|
expect(isValidIranNationalCode('0079460902')).toBe(true); // مقدار معتبر را با خروجی واقعی تابع تطبیق بده
|
||||||
|
expect(isValidIranNationalCode('1111111111')).toBe(false); // ارقام یکسان
|
||||||
|
expect(isValidIranNationalCode('123')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('imageUrl', () => {
|
||||||
|
it('مسیر /uploads را با NEXT_PUBLIC_API_URL پیشوند میدهد', () => {
|
||||||
|
expect(imageUrl('/uploads/a.jpg')).toContain('/uploads/a.jpg');
|
||||||
|
});
|
||||||
|
it('URL مطلق را دستنخورده برمیگرداند', () => {
|
||||||
|
expect(imageUrl('https://x.com/a.jpg')).toBe('https://x.com/a.jpg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
ضمناً query-builderها را پوشش بده: یک فیلتر UI نمونه بده و assert کن خروجی شامل نگاشت درست است (مثلاً `{ gender: 'مرد' }` → param `man`، `{ sort: '3' }` → `ASC`, `limit: 12`).
|
||||||
|
|
||||||
|
> **مهم:** قبل از نوشتن، `utils/index.js` را بخوان و **مقادیر assert را با خروجی واقعی تابع تطبیق بده** (بهویژه کدملی معتبر و فرمت دقیق `formatPhoneNumber`). حدس نزن — اگر assertion با رفتار واقعی نخواند، تست را با کد همراستا کن (نه برعکس) مگر باگ واقعی باشد.
|
||||||
|
|
||||||
|
### ۳. تست خالص `lib/`
|
||||||
|
|
||||||
|
`lib/appointmentSlots.test.js`:
|
||||||
|
```js
|
||||||
|
import { adaptSlots, hasAvailable } from '@/lib/appointmentSlots';
|
||||||
|
// اسلات با start_time '09:00' → morning ، '14:00' → evening
|
||||||
|
// hasAvailable: آرایه با حداقل یک is_available:true → true ؛ [] → false ؛ غیرآرایه → false
|
||||||
|
```
|
||||||
|
`lib/tokenStore.test.js` — `setAccessToken`/`getAccessToken`/`clearAccessToken`؛ `setAccessToken('')` → null.
|
||||||
|
`lib/sanitize.test.js` — `sanitizeHtml(non-string)` → `''`؛ تگهای مجاز حفظ، اسکریپت حذف؛ `safeJsonParse('{"a":1}')` → object، ورودی نامعتبر → fallback.
|
||||||
|
`lib/refreshCookie.test.js` — فقط `cookieDomain(host)`: `localhost`/IP → `undefined`؛ `arak-nobat.ir` → `.nobat.ir` (با خروجی واقعی تطبیق بده).
|
||||||
|
`lib/ability.test.js` — `defineAbilitiesFor({...}).can('access','Dashboard')` true و `can('access','Login')` false؛ بدون user برعکس.
|
||||||
|
`lib/representationAdapters.test.js` — `buildPatientUser` با ورودی double-nested `{ data: { data: {...} } }` → خروجی flatten با fallbackها.
|
||||||
|
|
||||||
|
### ۴. تشخیص چند-دامنهای (mock host / window / next/headers)
|
||||||
|
|
||||||
|
`lib/getStateInfo.test.js` (سرور — `next/headers` را mock کن):
|
||||||
|
```js
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
vi.mock('next/headers', () => ({
|
||||||
|
headers: () => ({ get: (k) => (k === 'host' ? 'arak-nobat.ir' : null) }),
|
||||||
|
cookies: () => ({ get: () => undefined }),
|
||||||
|
}));
|
||||||
|
import { getStateInfo } from '@/lib/getStateInfo';
|
||||||
|
// await getStateInfo() → matchedCity.domain شامل 'arak'، matchedState.id === province_id
|
||||||
|
```
|
||||||
|
> توجه: در Next 15، `headers()`/`cookies()` asyncاند ولی `await` روی مقدار غیر-Promise هم کار میکند؛ mock بالا کافی است.
|
||||||
|
|
||||||
|
`lib/getStateInfoClient.test.js` (کلاینت — `window.location.host` را ست کن):
|
||||||
|
```js
|
||||||
|
// Object.defineProperty(window, 'location', { value: { host: 'arak-nobat.ir' }, writable: true });
|
||||||
|
// getStateInfoClient().matchedCity شهر آراک را برمیگرداند
|
||||||
|
// در حالت SSR-guard (حذف window) → همه null (میتوانی این حالت را در یک تست جدا با vi.stubGlobal بسازی)
|
||||||
|
```
|
||||||
|
|
||||||
|
`lib/getCanonicalUrl.test.js` — `getCanonicalUrlClient(pathname, host)`: دامنهی اصلی `nobat724.com` → `null`؛ subdomain → `https://nobat724.com${pathname}`؛ `www.` حذف و lowercase (با امضای واقعی تابع تطبیق بده).
|
||||||
|
|
||||||
|
### ۵. سرویسها
|
||||||
|
|
||||||
|
`services/clinicApi.test.js` (mock global fetch):
|
||||||
|
```js
|
||||||
|
import { vi, beforeEach } from 'vitest';
|
||||||
|
import { getClinicDoctors } from '@/services/clinicApi';
|
||||||
|
|
||||||
|
beforeEach(() => vi.stubGlobal('fetch', vi.fn()));
|
||||||
|
|
||||||
|
it('پاسخ double-nested را به { data, page } تبدیل میکند', async () => {
|
||||||
|
fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({
|
||||||
|
data: { data: [{ uuid: 'd1' }], meta: { total_pages: 2, current: 1 } },
|
||||||
|
}) });
|
||||||
|
const out = await getClinicDoctors('my-clinic');
|
||||||
|
expect(out.data).toHaveLength(1);
|
||||||
|
expect(out.page.total_pages).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('خطا → shape پیشفرض خالی', async () => {
|
||||||
|
fetch.mockRejectedValue(new Error('net'));
|
||||||
|
const out = await getClinicDoctors('x');
|
||||||
|
expect(out.data).toEqual([]);
|
||||||
|
expect(out.page).toEqual({ total_pages: 1, current: 1 });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`services/response.test.js` — wrapperها قرارداد درست (method/path/options) را به لایهی axios میفرستند. `@/services/api` را mock کن:
|
||||||
|
```js
|
||||||
|
vi.mock('@/services/api', () => ({
|
||||||
|
default: { get: vi.fn(() => Promise.resolve({})), post: vi.fn(() => Promise.resolve({})),
|
||||||
|
patch: vi.fn(() => Promise.resolve({})), delete: vi.fn(() => Promise.resolve({})) },
|
||||||
|
}));
|
||||||
|
import api from '@/services/api';
|
||||||
|
import { request } from '@/services/response';
|
||||||
|
// چند wrapper نماینده را صدا بزن و assert کن api.get/post با path درست و { requireAuth: true } (هرجا لازم) فراخوانی شده
|
||||||
|
// مثلاً request.getUserInfo() → api.get با مسیر oauth/userinfo و requireAuth:true (با کد واقعی response.js تطبیق بده)
|
||||||
|
```
|
||||||
|
> منطق interceptorهای `services/api.js` (401 refresh، `extractErrorMessage`، dedup `refreshPromise`) پیچیده و وابسته به axios/toastify/tokenStore است؛ آن را **اختیاری/پیشرفته** بگذار. اگر وقت بود: `extractErrorMessage` را اگر export شده مستقیم تست کن، وگرنه روی wrapperهای `response.js` تمرکز کن.
|
||||||
|
|
||||||
|
### ۶. CASL context و یک کامپوننت
|
||||||
|
|
||||||
|
`context/ProvinceProvider.test.jsx` — با mock `window.location.hostname`، یک مصرفکنندهی `useProvince()` را render کن و assert کن `cityId` درست از `city.json` میآید:
|
||||||
|
```jsx
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ProvinceProvider, useProvince } from '@/context/ProvinceProvider';
|
||||||
|
function Probe() { const { cityId } = useProvince(); return <span>{String(cityId)}</span>; }
|
||||||
|
// Object.defineProperty(window,'location',{ value:{ hostname:'arak-nobat.ir' }, writable:true });
|
||||||
|
// render(<ProvinceProvider><Probe/></ProvinceProvider>) → cityId شهر آراک (useEffect → waitFor)
|
||||||
|
```
|
||||||
|
یک کامپوننت سادهی client (مثلاً یکی از `components/common/` یا یک کامپوننت presentational بدون fetch) را هم render-smoke کن. اگر کامپوننت از `next/navigation` استفاده میکند:
|
||||||
|
```js
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
|
||||||
|
usePathname: () => '/',
|
||||||
|
useSearchParams: () => new URLSearchParams(),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۷. اجرا و سبزکردن + lint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test # کل سوئیت
|
||||||
|
npm run test:cov # پوشش
|
||||||
|
npm run lint # ESLint بدون رگرسیون
|
||||||
|
```
|
||||||
|
همه باید سبز شوند. اگر تستی بهخاطر رفتار واقعی کد شکست خورد، تست را با رفتار واقعی همراستا کن (نه تغییر کد) مگر باگ واقعی باشد و آن را جدا گزارش بده.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **پروژه JS/JSX است، نه TS** — هیچ type annotation ننویس؛ فایل تستها `.js`/`.jsx`. plugin-react با `include: /\.(js|jsx)$/` تنظیم شود تا JSX داخل `.js` transform شود.
|
||||||
|
- **alias `@` به ریشهی پروژه اشاره میکند** (`@/utils`, `@/lib/...`, `@/services/...`, `@/context/...`) — نه به `src`.
|
||||||
|
- **React 18 (نه 19)** — `@testing-library/react` v16 سازگار است.
|
||||||
|
- `@vitejs/plugin-react` را **v4** پین کن و config را `.mjs` بگذار (همان pitfall ESM/vite5 که در نسخهی admin رخ داد).
|
||||||
|
- **Server Componentها و صفحات App Router** را مستقیم import نکن مگر `next/headers`/`next/navigation`/`next/cache` را mock کرده باشی. اولویت با لایهی خالص (`utils/`, `lib/`, `services/`) است که بیشترین ارزش و کمترین mock را دارد.
|
||||||
|
- `next/headers` و `next/navigation` در jsdom وجود ندارند → هرجا کد آنها را صدا میزند `vi.mock` کن.
|
||||||
|
- **توابع تاریخ Jalali** (`convertToJalali`, `convertTimestampToJalali`, …) به `moment-jalaali`/timezone وابستهاند؛ بهجای assert رشتهی دقیق، روی «خروجی غیرخالی / شامل رقم فارسی / طول مورد انتظار» assert کن تا تست با timezone شکننده نشود.
|
||||||
|
- **سه نقطهی تشخیص شهر** (`getStateInfo` سرور با `===`، `getStateInfoClient` کلاینت با `.includes`، `ProvinceProvider`) منطق کمی متفاوت دارند — هرکدام را با host مناسب جدا تست کن و این تفاوت را در تست منعکس کن.
|
||||||
|
- **پوشش را صریح گزارش کن**: با ۵۰۵ فایل کامپوننت و ۱۶ صفحه، پوشش کامل در یک اجرا غیرواقعی است. لایهی خالص + سرویس + تشخیص دامنه + CASL را کامل کن، و فایلهای پوششنداده را فهرست کن (silent truncation ممنوع).
|
||||||
|
- این تغییر فقط frontend سایت عمومی است؛ backend (`clinicpro`) و قرارداد API دست نمیخورد.
|
||||||
|
- بعد از پایان حتماً `npm run build` را **اجرا نکن** برای تست (کند و غیرضروری)؛ فقط `npm run test` و `npm run lint`. مطمئن شو افزودن deps تست build را نمیشکند (Vitest کاملاً مستقل از Next build است).
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# ارسال دامنهی شهر همراه درخواست کد تأیید (برای اسم سایت در پیامک OTP)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — **cross-repo**؛ پرامپت همتای backend: `clinicpro/.claude/prompt/otp-sms-site-name.md` (اول backend اجرا شود).
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
سایت چند-دامنهای است؛ هر شهر یک دامنه (`yasuj-nobat.ir`, `ahvaz-nobat.ir`, ...). backend قابلیت اضافهشدن اسم سایتِ شهر به پیامک کد تأیید را دارد، اما چون درخواست به `api.clinic-pro.ir` میرود، backend نمیداند از کدام شهر آمده. پس **frontend باید دامنهی خودش را در body درخواست `send-code` بفرستد.**
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
فراخوانی `send-code` فعلی فقط `mobile` و `captcha_token` میفرستد. باید فیلد `domain` (دامنهی فعلی مرورگر) هم ارسال شود تا backend اسم سایت شهر را در پیامک قرار دهد. تغییر باید ایمن باشد: اگر domain در دسترس نبود، ارسال نشود (backend fallback دارد).
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `services/response.js` | wrapper فراخوانی `request.sendCode` |
|
||||||
|
| `components/register/LogInPage.js` | صفحه لاگین — فراخوانی `sendCode` (خط ۲۷) |
|
||||||
|
| `components/register/verificationPage/Recode.js` | ارسال مجدد کد — فراخوانی `sendCode` (خط ۵۲) |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
`services/response.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const request = {
|
||||||
|
sendCode: (mobile, captcha_token) =>
|
||||||
|
api.post(
|
||||||
|
"api/v1/user/send-code",
|
||||||
|
{
|
||||||
|
mobile,
|
||||||
|
captcha_token,
|
||||||
|
},
|
||||||
|
removeTokenHead
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
فراخوانیها (client component، داخل مرورگر):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// components/register/LogInPage.js:27
|
||||||
|
.sendCode(num, "")
|
||||||
|
// components/register/verificationPage/Recode.js:52
|
||||||
|
.sendCode(changeNumToDefault(num), "")
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. افزودن `domain` به wrapper `sendCode`
|
||||||
|
|
||||||
|
`services/response.js` — پارامتر سوم اختیاری `domain`، فقط اگر مقدار داشت به body اضافه شود:
|
||||||
|
|
||||||
|
```js
|
||||||
|
sendCode: (mobile, captcha_token, domain) =>
|
||||||
|
api.post(
|
||||||
|
"api/v1/user/send-code",
|
||||||
|
{
|
||||||
|
mobile,
|
||||||
|
captcha_token,
|
||||||
|
...(domain ? { domain } : {}),
|
||||||
|
},
|
||||||
|
removeTokenHead
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. پاسدادن دامنهی فعلی از صفحههای فراخوان
|
||||||
|
|
||||||
|
چون این کامپوننتها client هستند، دامنه از `window.location.hostname` گرفته میشود (SSR-safe با گارد).
|
||||||
|
|
||||||
|
`components/register/LogInPage.js` (خط ۲۷):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const domain = typeof window !== "undefined" ? window.location.hostname : "";
|
||||||
|
// ...
|
||||||
|
.sendCode(num, "", domain)
|
||||||
|
```
|
||||||
|
|
||||||
|
`components/register/verificationPage/Recode.js` (خط ۵۲):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const domain = typeof window !== "undefined" ? window.location.hostname : "";
|
||||||
|
// ...
|
||||||
|
.sendCode(changeNumToDefault(num), "", domain)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `window.location.hostname` مقدار خالص دامنه میدهد (مثل `yasuj-nobat.ir`) بدون پروتکل — دقیقاً همان چیزی که backend برای lookup در `cities.domain` میخواهد.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **اختیاری و ایمن**: اگر `domain` نبود (مثلاً `localhost` یا دامنهی اصلی)، backend fallback به برند «نوبت ۷۲۴» دارد؛ پس هیچ رفتار موجودی نمیشکند.
|
||||||
|
- روی `localhost`/دامنهی توسعه، `hostname` مقدار غیرشهری میدهد → backend شهر پیدا نمیکند → برند پیشفرض. اشکالی ندارد.
|
||||||
|
- **قرارداد API**: فیلد جدید body = `domain` (string، اختیاری) در `POST /api/v1/user/send-code`. مطابق backend نسخهی `clinicpro/.claude/prompt/otp-sms-site-name.md`. مطمئن شو backend اول اجرا/دیپلوی شده باشد.
|
||||||
|
- این تغییر client-side است؛ نیازی به `generateMetadata` یا تغییر SSR ندارد.
|
||||||
|
- تست:
|
||||||
|
```bash
|
||||||
|
cd nobat724_front
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
- الگوی پروژه: از همان `request.sendCode` استفاده کن؛ فراخوانی مستقیم axios نساز.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# جریان پرداخت سمت کلاینت — انتقال از طریق بکاند + صفحات نتیجه (Frontend)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی). **cross-repo** — پرامپت همتا (Backend، اول اجرا شود): `clinicpro/.claude/prompt/payment-flow-architecture.md`. قرارداد API توسط این کلاینت مصرف میشود.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
طبق معمار: هیچ Frontend نباید مستقیم با بانک صحبت کند؛ Backend مرجع است و انتقال به بانک و بازگشت از طریق Backend انجام میشود. بخش زیادی **قبلاً پیاده شده**:
|
||||||
|
|
||||||
|
- `components/appointment/paying/index.js`: درگاهها از `GET /api/v1/payment/config` (`data.gateways` فعال) خوانده میشوند؛ کلیک پرداخت → `POST /api/v1/payment/appointment` (authenticated XHR) → دریافت `pay_url` → `window.location.href = pay_url` (انتقال full-page به بکاند؛ بکاند خودش به بانک میرود).
|
||||||
|
- `frontend_address = ${window.location.origin}/payment/result` → بکاند بعد از callback به همین دامنهٔ مبدأ برمیگردد با `?payment_uuid=..&status=..`.
|
||||||
|
- `app/payment/result/page.js` وضعیت را میخواند و به `/payment/${uuid}` هدایت میکند.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
طبق spec باید این موارد دقیق و کامل باشند:
|
||||||
|
|
||||||
|
1. **کلاینت هرگز مستقیم به بانک نرود** — فقط به `pay_url` (بکاند). (عمدتاً انجام شده؛ باید تأیید و تثبیت شود.)
|
||||||
|
2. **صفحات نتیجهٔ استاندارد** — `success` / `failed` با نمایش وضعیت پرداخت و لینکهای اقدام، بهجای هدایت خام.
|
||||||
|
3. **سازگاری با تغییر قرارداد Backend** — اگر Backend پاسخ `GET /api/v1/payment/{uuid}` را از double-nested به flat تغییر داد (وظیفهٔ backend)، خواندن در `app/payment/[uuid]/page.js` باید هماهنگ شود.
|
||||||
|
4. **حالت انصراف/شکست** — وقتی `status` برابر `canceled`/`failed` است، پیام مناسب و امکان تلاش مجدد.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/appointment/paying/index.js` | دکمهٔ پرداخت + انتخاب درگاه + هدایت به `pay_url` |
|
||||||
|
| `app/payment/result/page.js` | صفحهٔ بازگشت از بکاند (`?payment_uuid&status`) |
|
||||||
|
| `app/payment/[uuid]/page.js` | نمایش نتیجهٔ پرداخت بر اساس `getPayment` |
|
||||||
|
| `services/response.js` | `postAppointmentPayment`, `getPaymentConfig`, `getPayment` |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
```js
|
||||||
|
// components/appointment/paying/index.js (کلیک پرداخت)
|
||||||
|
const res = await request.postAppointmentPayment({
|
||||||
|
appointment_uuid: appointmentId,
|
||||||
|
gateway: testMode ? "mellat" : selectedBank,
|
||||||
|
frontend_address: `${window.location.origin}/payment/result`,
|
||||||
|
});
|
||||||
|
const payUrl = res?.data?.pay_url || res?.data?.redirect_url;
|
||||||
|
if (payUrl) window.location.href = payUrl; // انتقال full-page به بکاند ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// app/payment/result/page.js (بازگشت از بکاند)
|
||||||
|
const paymentUuid = searchParams.get("payment_uuid");
|
||||||
|
if (paymentUuid) router.replace(`/payment/${paymentUuid}`);
|
||||||
|
else router.replace("/dashboard?sidebar=2");
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. تثبیت انتقال از طریق بکاند (بدون تماس مستقیم با بانک)
|
||||||
|
|
||||||
|
مطمئن شو در `handlePayment` هیچ URL بانکی مستقیم باز نمیشود؛ فقط `pay_url`. اگر `pay_url` نبود (پاسخ ناقص)، بهجای رفتن به مرحلهٔ بعد، خطای کاربرپسند نشان بده:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const payUrl = res?.data?.pay_url;
|
||||||
|
if (payUrl) { window.location.href = payUrl; return; }
|
||||||
|
toast/alert("خطا در شروع پرداخت. دوباره تلاش کنید.");
|
||||||
|
setLoading(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
(اتکا به `redirect_url` بانک را حذف کن؛ منبع درست فقط `pay_url` است.)
|
||||||
|
|
||||||
|
### ۲. صفحهٔ نتیجه بر اساس `status`
|
||||||
|
|
||||||
|
`app/payment/result/page.js` علاوه بر `payment_uuid`، پارامتر `status` را هم بخواند و بر اساس آن رفتار کند:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const status = searchParams.get("status"); // success | failed | canceled | pending
|
||||||
|
const paymentUuid = searchParams.get("payment_uuid");
|
||||||
|
if (status === "success" && paymentUuid) router.replace(`/payment/${paymentUuid}`);
|
||||||
|
else router.replace(`/payment/${paymentUuid ?? ""}?status=${status ?? "failed"}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
در `app/payment/[uuid]/page.js` وضعیت را از `getPayment(uuid)` بگیر (source of truth بکاند، نه فقط query) و کارت نتیجه را نشان بده: موفق (سبز، جزئیات نوبت/کدرهگیری)، ناموفق/لغو (قرمز، دکمهٔ «تلاش مجدد» → بازگشت به صفحهٔ رزرو پزشک).
|
||||||
|
|
||||||
|
### ۳. سازگاری با شکل پاسخ `getPayment`
|
||||||
|
|
||||||
|
اگر Backend (وظیفهٔ همتا) پاسخ `GET /api/v1/payment/{uuid}` را flat کرد، خواندن را طوری بنویس که هر دو حالت کار کند:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const data = await request.getPayment(uuid);
|
||||||
|
const payment = data?.data?.data ?? data?.data; // سازگار با flat و nested
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۴. UX انصراف/شکست
|
||||||
|
|
||||||
|
اگر `status !== success`: پیام «پرداخت انجام نشد یا لغو شد» + وضعیت واقعی از `payment.status` + دکمهٔ تلاش مجدد. هیچ اطلاعات حساسی نمایش نده.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **قرارداد API را از Backend بگیر:** `POST /api/v1/payment/appointment` → `{ payment_uuid, pay_url, order_id }`؛ بازگشت callback → `frontend_address?payment_uuid&status`. (منبع: `clinicpro/docs/api/payment.md`.)
|
||||||
|
- **چرا یک XHR لازم است:** ساخت `Payment` نیازمند احراز مالکیت سفارش است و JWT در کوکیِ همین دامنه است؛ redirect full-page کوکی cross-domain نمیبرد. پس الگوی «XHR authenticated برای ساخت + سپس redirect full-page به `pay_url`» درست و امن است — این را حفظ کن (نه fetch مستقیم به بانک).
|
||||||
|
- App Router؛ صفحات نتیجه `generateMetadata` با `noindex` داشته باشند (صفحهٔ تراکنش نباید ایندکس شود).
|
||||||
|
- استایل MUI v5 + Tailwind، RTL، فونت Vazir، Jalali؛ کلاسهای موجود.
|
||||||
|
- API client فقط از طریق `services/response.js` → `request.*`؛ `getPayment` با `{ requireAuth: true }`.
|
||||||
|
- بعد از تغییر: `npm run build` در `nobat724_front`.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# entry پرداخت بهصورت ریدایرکت خالص به Backend (بدون XHR) — سایت عمومی
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی). **cross-repo** — پرامپت همتا (Backend، اول اجرا شود): `clinicpro/.claude/prompt/payment-single-flow-consolidation.md`.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
طبق معماری واحد پرداخت، سایت عمومی نباید هیچ API برای **شروع** پرداخت صدا بزند؛ فقط مرورگر را به Backend ریدایرکت کند. Backend یک entry ریدایرکتِ خالص دارد:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET {API}/api/v1/payment/order/{appointmentUuid}?gateway=<name>&return=<frontend_return_url>
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend این آدرس را میگیرد، سفارش را اعتبارسنجی میکند، `Payment` میسازد، به بانک وصل میشود و کاربر را به شاپرک میفرستد؛ در پایان به `return` (همان دامنهٔ مبدأ) با `?status=...` برمیگردد.
|
||||||
|
|
||||||
|
وضعیت فعلی سایت: `components/appointment/paying/index.js` هنوز با **XHR** (`request.postAppointmentPayment`) پرداخت را شروع میکند و بعد به `pay_url` میرود. طبق نیاز جدید باید این XHR حذف شود و دکمهٔ پرداخت مستقیماً به `payment/order/{uuid}` ریدایرکت کند.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
حذف کامل فراخوانی API برای شروع پرداخت در سایت عمومی؛ دکمهٔ پرداخت = ریدایرکت مرورگر به `GET {API}/api/v1/payment/order/{appointmentUuid}?gateway=...&return=...`. انتخاب درگاه (در صورت چند درگاه) قبل از ریدایرکت انجام شود؛ اگر یک درگاه فعال باشد، خودکار.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/appointment/paying/index.js` | دکمهٔ پرداخت + انتخاب درگاه (از `getPaymentConfig`) |
|
||||||
|
| `services/response.js` | `getPaymentConfig` (برای فهرست درگاههای فعال) — بدون تغییر |
|
||||||
|
| `app/payment/result/page.js` | بازگشت از Backend با `status` — بدون تغییر |
|
||||||
|
| `app/payment/[uuid]/page.js` | نمایش نتیجه — بدون تغییر |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
```js
|
||||||
|
// components/appointment/paying/index.js
|
||||||
|
const handlePayment = async () => {
|
||||||
|
if (!appointmentId) return;
|
||||||
|
if (!testMode && !selectedBank) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await request.postAppointmentPayment({ // ❌ XHR برای شروع پرداخت
|
||||||
|
appointment_uuid: appointmentId,
|
||||||
|
gateway: testMode ? "mellat" : selectedBank,
|
||||||
|
frontend_address: `${window.location.origin}/payment/result`,
|
||||||
|
});
|
||||||
|
const payUrl = res?.data?.pay_url;
|
||||||
|
if (payUrl) { window.location.href = payUrl; return; }
|
||||||
|
throw new Error("pay_url missing");
|
||||||
|
} catch (error) { alert("خطا در شروع پرداخت..."); setLoading(false); }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`getPaymentConfig` از قبل درگاههای فعال را در `gateways` میدهد و `selectedBank`/`testMode` ست میشوند (بدون تغییر میمانند).
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. تبدیل دکمهٔ پرداخت به ریدایرکتِ خالص (حذف XHR)
|
||||||
|
|
||||||
|
`handlePayment` را طوری بازنویسی کن که بهجای XHR، مستقیماً مرورگر را به entry بکاند ببرد:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const handlePayment = () => {
|
||||||
|
if (!appointmentId) return;
|
||||||
|
const gateway = testMode ? "mellat" : selectedBank;
|
||||||
|
if (!gateway) return; // باید یک درگاه انتخاب شده باشد
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const apiBase = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
const ret = encodeURIComponent(`${window.location.origin}/payment/result`);
|
||||||
|
window.location.href =
|
||||||
|
`${apiBase}/api/v1/payment/order/${appointmentId}` +
|
||||||
|
`?gateway=${encodeURIComponent(gateway)}&return=${ret}`;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- `handlePayment` دیگر `async` نیست و هیچ `request.*` صدا نمیزند.
|
||||||
|
- `NEXT_PUBLIC_API_URL` همان base بکاند است (مثل بقیهٔ سایت).
|
||||||
|
- `appointmentId` همان `appointment_uuid` است (capability؛ در URL میرود).
|
||||||
|
|
||||||
|
### ۲. حفظ انتخاب درگاه (مرحلهٔ ۲ نیاز)
|
||||||
|
|
||||||
|
- `getPaymentConfig` و state `gateways`/`selectedBank`/`testMode` بدون تغییر بمانند؛ Select فقط وقتی چند درگاه فعال است نمایش داده شود.
|
||||||
|
- اگر فقط یک درگاه فعال باشد، `selectedBank` از قبل روی همان ست است (پیشفرض `gateways[0]`), پس مرحله خودکار است.
|
||||||
|
- دکمه وقتی `!testMode && !selectedBank` است `disabled` بماند (از قبل هست).
|
||||||
|
|
||||||
|
### ۳. پاکسازی
|
||||||
|
|
||||||
|
- اگر `request.postAppointmentPayment` دیگر جای دیگری استفاده نمیشود، آن را از `services/response.js` حذف کن (بررسی با grep). اگر جای دیگری استفاده میشود، نگهدار.
|
||||||
|
- `app/payment/result/page.js` و `app/payment/[uuid]/page.js` بدون تغییر (بازگشت با `status` را از قبل مدیریت میکنند).
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **هیچ فراخوانی API برای شروع پرداخت نباید بماند** — فقط ریدایرکت full-page به `{API}/api/v1/payment/order/{uuid}`. Backend همهٔ ارتباط با بانک را انجام میدهد.
|
||||||
|
- **بازگشت به دامنهٔ مبدأ:** `return=${window.location.origin}/payment/result` تضمین میکند Backend بعد از پرداخت به همین دامنهٔ چند-شهری برگردد؛ Backend این آدرس را در برابر `payment_allowed_frontend_hosts` اعتبارسنجی میکند (host دامنه باید whitelist باشد).
|
||||||
|
- انتخاب درگاه همچنان از `GET /api/v1/payment/config` (`gateways` فعال) است — درگاه هاردکد نکن.
|
||||||
|
- App Router؛ `paying` یک client component است (`use client`). فونت/استایل موجود (MUI + Tailwind, RTL).
|
||||||
|
- بعد از تغییر: `npm run build`. تست دستی: کلیک پرداخت → مرورگر به `{API}/api/v1/payment/order/...` میرود (نه XHR)، سپس شاپرک، سپس بازگشت به `/payment/result?status=...`.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# رفع جهت تبدیل تاریخ تولد هنگام ایجاد پروفایل
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی، داشبورد).
|
||||||
|
|
||||||
|
> **Cross-repo:** وابسته به پرامپت بکاند `clinicpro/.claude/prompt/profile-birthday-jalali-persist.md` (که `birthday` را بهصورت Unix ذخیره/برمیگرداند). آن **اول** اجرا شود.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
تاریخ تولد در فرم داشبورد بهصورت رشتهی جلالی از `JalaliDatePicker` میآید (`information.birthday = "jYYYY/jM/jD"`). تابع `changeDateType(data, toTimeStamp)` در `helper/index.js` بین جلالیرشته و Unix تبدیل میکند:
|
||||||
|
- `toTimeStamp=true` → `moment(birthday, "jYYYY/jMM/jDD").unix()` (جلالی → Unix، برای **ارسال**)
|
||||||
|
- `toTimeStamp=false` → `moment.unix(birthday).format("jYYYY/jMM/jDD")` (Unix → جلالی، برای **نمایش**)
|
||||||
|
|
||||||
|
بکاند `birthday` را بهصورت **Unix** میپذیرد و برمیگرداند. مسیر بهروزرسانی (`patchData`) درست `changeDateType(usedKays, true)` میزند. اما مسیر ایجاد (`postData`) بهاشتباه `changeDateType(information, false)` میزند — یعنی هنگام **ارسال**، جهت تبدیل برعکس است و `moment.unix(جلالی-رشته)` یک مقدار بیمعنا تولید میکند. در نتیجه هنگام ایجاد پروفایل، تاریخ تولد خراب ذخیره میشود.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
در `postData` جهت `changeDateType` باید `true` باشد (مثل `patchData`)، تا `birthday` جلالی به Unix تبدیل و سپس ارسال شود.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/dashboard/userAccount/detailUser/information/ButtonSendData.js` | `postData` (ایجاد) — جهت اشتباه |
|
||||||
|
| `helper/index.js` | `changeDateType` (مرجع — تغییر نمیکند) |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const postData = () => {
|
||||||
|
request
|
||||||
|
.postUserProfile(changeDateType(information, false)) // ❌ باید true باشد (مثل patchData)
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchData = () => {
|
||||||
|
const usedKays = removeAdditionalKeysDashboard(information);
|
||||||
|
request
|
||||||
|
.patchUserProfile(changeDateType(usedKays, true), information?.uuid, ...) // ✅ درست
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. اصلاح جهت در `postData`
|
||||||
|
```js
|
||||||
|
request.postUserProfile(changeDateType(information, true))
|
||||||
|
```
|
||||||
|
- فقط `false` → `true`. بقیهی منطق `postData` بدون تغییر.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- `changeDateType` و `patchData` تغییر نمیکنند؛ فقط یک آرگومان در `postData` اصلاح میشود.
|
||||||
|
- نمایش (load) از قبل با `changeDateType(changedData, false)` در `information/index.js` درست است و `birthday` که حالا بکاند بهصورت Unix میدهد را به جلالی تبدیل میکند — تغییری لازم ندارد.
|
||||||
|
- بعد از تغییر: `npm run build` بدون خطا؛ تست دستی — ایجاد پروفایل جدید با تاریخ تولد → ذخیره و سپس نمایش درستِ همان تاریخ؛ و بهروزرسانی هم همچنان درست کار کند.
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
# رزرو آنلاین منبعمحور در جریان نوبتگیری
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی).
|
||||||
|
|
||||||
|
پرامپت همتا در backend: `clinicpro/.claude/prompt/public-resource-booking-api.md`.
|
||||||
|
**آن باید اول اجرا و merge شود** — این پرامپت سه اندپوینتی را مصرف میکند که آنجا ساخته میشوند.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
در کلینیکپرو، «منبع» هر چیزی است که ممکن است اشغال باشد: پزشک، اپراتور، دستگاه، اتاق، یونیت.
|
||||||
|
هر منبع تقویم مستقل خودش را دارد و سرویسهایی که ارائه میدهد، با مدت و قیمتِ اختصاصیِ همان منبع،
|
||||||
|
روی خودش تعریف میشوند. مالک کلینیک با توگل «نمایش در نوبتدهی آنلاین» روی هر سرویس تعیین میکند
|
||||||
|
که آن سرویس در سایت دیده شود یا نه.
|
||||||
|
|
||||||
|
جریان فعلیِ نوبتگیری سایت فقط پزشکمحور است: محل → (در حالت سرویسی) سرویس → روز → ساعت.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
بیمار نمیتواند روی یک دستگاه یا اتاقِ مشخص نوبت بگیرد. هدف: افزودن یک مرحلهٔ «انتخاب منبع» به
|
||||||
|
همان جریان، بدون شکستن مسیر فعلی.
|
||||||
|
|
||||||
|
**تصمیم دامنه (تأییدشده):**
|
||||||
|
|
||||||
|
- نقطهٔ ورود فقط `/appointment/[doctorId]` است. صفحهٔ کلینیک در این تسک دست نمیخورد.
|
||||||
|
- فقط منابعِ همان پزشک نمایش داده میشوند (backend خودش فیلتر میکند؛ سایت فیلتر اضافه نمیزند).
|
||||||
|
- اگر پزشک در آن محل هیچ منبعی نداشته باشد، **هیچ تغییری در تجربهٔ فعلی دیده نمیشود** —
|
||||||
|
مرحلهٔ منبع اصلاً رندر نمیشود.
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
|
||||||
|
- ✅ موفق:
|
||||||
|
- پزشکی که در محل انتخابشده منبعِ قابل رزرو دارد: بعد از تأیید محل، مرحلهٔ «انتخاب منبع»
|
||||||
|
دیده میشود با کارت «نوبت با پزشک» بهعلاوهٔ یک کارت به ازای هر منبع.
|
||||||
|
- انتخاب یک منبع → فهرست سرویسهای **همان منبع** با مدت و قیمتِ همان منبع → تقویم → ساعتها
|
||||||
|
از `appointment-resource-slots` → ثبت نوبت با `resource_uuid` در payload → پاسخ ۲۰۱ و رفتن
|
||||||
|
به مرحلهٔ پرداخت، مثل جریان فعلی.
|
||||||
|
- انتخاب «نوبت با پزشک» → دقیقاً جریان امروز، بدون هیچ تفاوتی.
|
||||||
|
- ❌ خطا:
|
||||||
|
- خطای شبکه یا ۴۲۲ روی `getBookingResources` → مرحلهٔ منبع رندر نشود و جریان پزشکمحور ادامه
|
||||||
|
یابد. صفحه نباید سفید شود یا در حالت loading گیر کند.
|
||||||
|
- ثبت نوبت با ۴۰۹ (منبع در آن لحظه پر شد) → پیام فارسی به کاربر و برگشت به مرحلهٔ ساعت،
|
||||||
|
نه خطای خام.
|
||||||
|
- ⚠️ مرزی:
|
||||||
|
- پزشک بدون منبع → صفر تغییر در UI فعلی (این را صریح تست کن، نه فرض).
|
||||||
|
- منبعی که در روز انتخابشده شیفت ندارد → پیام «در حال حاضر، نوبتی برای این روز موجود نمیباشد.»
|
||||||
|
مثل امروز، نه فهرست خالی و بیتوضیح.
|
||||||
|
- تعویض محل بعد از انتخاب منبع → منبع و سرویس و ساعتِ انتخابشده باید باطل شوند. امروز
|
||||||
|
`changeLocation` همین کار را برای سرویس و اسلات میکند؛ منبع هم باید به آن اضافه شود.
|
||||||
|
- تعویض منبع بعد از انتخاب سرویس → سرویسها باطل شوند (سرویسِ منبع الف روی منبع ب معتبر نیست
|
||||||
|
و backend با ۴۲۲ رد میکند).
|
||||||
|
- منبعِ با ظرفیت بیش از ۱: ساعتها ممکن است با نوبتِ موجود همپوشان باشند — این درست است و
|
||||||
|
سایت نباید فیلتر اضافه بزند.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `services/response.js` | wrapper همهٔ فراخوانیهای API |
|
||||||
|
| `components/appointment/index.js` | state جریان نوبت: محل، سرویس، اسلات |
|
||||||
|
| `components/appointment/Container.js` | تصمیمِ اینکه کدام مرحله رندر شود |
|
||||||
|
| `components/appointment/location/LocationSelect.js` | الگوی مرجعِ ظاهرِ کارت انتخاب |
|
||||||
|
| `components/appointment/service/index.js` | مرحلهٔ انتخاب سرویس |
|
||||||
|
| `components/appointment/date/index.js`, `date/hour/index.js` | مرحلهٔ روز و ساعت |
|
||||||
|
| `app/component/date/dateTime/index.js` | فراخوانیِ اسلاتها |
|
||||||
|
| `app/component/date/datePicker/index.js` | تقویم و روزهای غیرفعال ماه |
|
||||||
|
| `components/appointment/detail/SubmitData.js` | ساخت payload و `postAppointment` |
|
||||||
|
| `lib/appointmentSlots.js` | `adaptServiceSlots` — همان شکل `start_times` |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
انتخاب مرحله در `Container.js` یک زنجیرهٔ `if/else` است:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// components/appointment/Container.js
|
||||||
|
let dateStep;
|
||||||
|
if (bookingLocations.length === 0) {
|
||||||
|
dateStep = (
|
||||||
|
<p className="w-full max-w-[520px] mx-auto py-[40px] text-center text-[14px] text-[#7A7A7A]">
|
||||||
|
نوبتدهی آنلاین برای این پزشک فعال نیست.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
} else if (multiLocation && !locationConfirmed) {
|
||||||
|
dateStep = (
|
||||||
|
<LocationSelect locations={bookingLocations} selected={selectedLocation} onSelect={changeLocation} />
|
||||||
|
);
|
||||||
|
} else if (serviceMode && selectedServiceUuids.length === 0) {
|
||||||
|
dateStep = (
|
||||||
|
<ServiceSelect services={bookingServices} onContinue={(uuids) => setSelectedServiceUuids(uuids)} />
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dateStep = ( <Date ... /> );
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
باطلسازی با تعویض محل:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// components/appointment/index.js
|
||||||
|
const changeLocation = (location) => {
|
||||||
|
setSelectedLocation(location);
|
||||||
|
setLocationConfirmed(true);
|
||||||
|
setSelectedServiceUuids([]);
|
||||||
|
setSelectedSlot(null);
|
||||||
|
setSelectedDate(null);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
اسلاتها امروز فقط دو حالت دارند:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// app/component/date/dateTime/index.js
|
||||||
|
const req = serviceMode
|
||||||
|
? request
|
||||||
|
.getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid)
|
||||||
|
.then(adaptServiceSlots)
|
||||||
|
: request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots);
|
||||||
|
```
|
||||||
|
|
||||||
|
و روزهای غیرفعال تقویم فقط پزشکمحورند:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// app/component/date/datePicker/index.js
|
||||||
|
request
|
||||||
|
.getMonthAvailability(doctorUuid, year, month, clinicUuid)
|
||||||
|
```
|
||||||
|
|
||||||
|
payload ثبت نوبت هنوز `resource_uuid` ندارد:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// components/appointment/detail/SubmitData.js
|
||||||
|
const appointmentPayload = {
|
||||||
|
doctor_uuid: doctor.uuid,
|
||||||
|
clinic_uuid: clinicUuid,
|
||||||
|
slot_start: selectedSlot.start,
|
||||||
|
slot_end: selectedSlot.end,
|
||||||
|
// ...
|
||||||
|
...(selectedServiceUuids?.length ? { service_item_uuids: selectedServiceUuids } : {}),
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. سه wrapper جدید در `services/response.js`
|
||||||
|
|
||||||
|
کنار همتاهای پزشکمحور، با همان سبک و همان `removeTokenHead` (این اندپوینتها عمومیاند):
|
||||||
|
|
||||||
|
```js
|
||||||
|
getBookingResources: (doctor_uuid, clinic_uuid = null) =>
|
||||||
|
api.get(
|
||||||
|
`api/v1/appointment-booking-resources/${doctor_uuid}${clinicQuery(clinic_uuid, "?")}`,
|
||||||
|
removeTokenHead
|
||||||
|
),
|
||||||
|
getResourceSlots: (resource_uuid, date, serviceItemUuids = []) =>
|
||||||
|
api.get(
|
||||||
|
`api/v1/appointment-resource-slots?resource_uuid=${resource_uuid}&date=${date}` +
|
||||||
|
serviceItemUuids
|
||||||
|
.map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`)
|
||||||
|
.join(""),
|
||||||
|
removeTokenHead
|
||||||
|
),
|
||||||
|
getResourceMonthAvailability: (resource_uuid, year, month, serviceItemUuids = []) =>
|
||||||
|
api.get(`api/v1/appointment-resource-month-availability/${resource_uuid}`, {
|
||||||
|
params: { year, month },
|
||||||
|
...removeTokenHead,
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
نکته: `service_item_uuids[]` آرایه است و `params` اکسیوس آن را با `[]` سریالایز نمیکند — به همین
|
||||||
|
دلیل `getServiceSlots` موجود هم دستی رشته میسازد. برای `month-availability` هم همان کار را بکن
|
||||||
|
(رشتهٔ query دستی)، وگرنه backend سرویسها را نمیبیند و مدت را نمیتواند حساب کند.
|
||||||
|
|
||||||
|
**نحوه تست:** در devtools، هر سه فراخوانی باید بدون هدر `Authorization` بروند و ۲۰۰ بگیرند.
|
||||||
|
|
||||||
|
### ۲. state منبع در `components/appointment/index.js`
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const [resources, setResources] = useState([]);
|
||||||
|
const [selectedResource, setSelectedResource] = useState(null); // null = نوبت با پزشک
|
||||||
|
const [resourceConfirmed, setResourceConfirmed] = useState(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
- با تغییر `selectedLocation`، منابع همان محل گرفته شوند:
|
||||||
|
`request.getBookingResources(doctor.uuid, selectedLocation?.clinic_uuid ?? null)`.
|
||||||
|
خطا → `setResources([])` (جریان فعلی ادامه یابد؛ سکوت عمدی است، نه فراموشی — کامنتش را بنویس).
|
||||||
|
- `changeLocation` باید `selectedResource`، `resourceConfirmed` و `resources` را هم باطل کند.
|
||||||
|
- تابع `changeResource(resource)` که `selectedServiceUuids`، `selectedSlot`، `selectedDate` را
|
||||||
|
صفر میکند و `resourceConfirmed = true` میگذارد.
|
||||||
|
- وقتی `resources.length === 0`، مقدار `resourceConfirmed` باید از ابتدا `true` باشد تا مرحله
|
||||||
|
اصلاً رندر نشود.
|
||||||
|
- سرویسهایی که به `ServiceSelect` میروند: اگر منبع انتخاب شده، `selectedResource.services`؛
|
||||||
|
وگرنه همان `selectedLocation?.services` امروز.
|
||||||
|
- وقتی منبع انتخاب شده، جریان **همیشه** سرویسی است (منبع اسلاتِ ثابت ندارد)، حتی اگر
|
||||||
|
`booking_mode` آن محل `slot` باشد.
|
||||||
|
|
||||||
|
**نحوه تست:** با `09390039833 / 09390039833` در پنل `/admin` یک منبع بساز، به آن یک سرویس با
|
||||||
|
«نمایش در نوبتدهی آنلاین» روشن وصل کن، سپس صفحهٔ نوبتِ همان پزشک را در
|
||||||
|
`http://yazd-nobat.localhost:3000/appointment/<doctor-uuid>` باز کن.
|
||||||
|
بعد همان سرویس را در پنل خاموش کن و رفرش بزن — مرحلهٔ منبع باید ناپدید شود.
|
||||||
|
|
||||||
|
### ۳. کامپوننت `components/appointment/resource/index.js`
|
||||||
|
|
||||||
|
کارتهای انتخاب، **دقیقاً با ظاهر و کلاسهای `LocationSelect.js` و `ServiceSelect`** — طراحی
|
||||||
|
جدید نساز، رنگ جدید معرفی نکن. رنگهای موجود: `#5559CE` (اصلی)، `#3B3B3B` (متن)، `#7A7A7A`
|
||||||
|
(متن ثانویه)، `#EFEFEF` (خط).
|
||||||
|
|
||||||
|
محتوای هر کارت:
|
||||||
|
|
||||||
|
- کارت اول همیشه: «نوبت با پزشک» با زیرعنوانِ کوتاه — انتخابش یعنی `changeResource(null)`.
|
||||||
|
- هر منبع: `name` بهعنوان عنوان، `type.name` بهعنوان برچسبِ نوع، تعداد سرویسها و کوتاهترین
|
||||||
|
مدت بهعنوان زیرنویس.
|
||||||
|
- عنوان مرحله: «۱. انتخاب نوع نوبت» — و در نتیجه سرویس «۲.» و ساعت «۳.» میشود. شمارههای
|
||||||
|
موجود در `ServiceSelect` و `Hour` باید هماهنگ شوند، وگرنه دو مرحله هر دو «۱.» میشوند.
|
||||||
|
|
||||||
|
در `Container.js` این مرحله **بعد از** تأیید محل و **قبل از** انتخاب سرویس بنشیند:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
} else if (resources.length > 0 && !resourceConfirmed) {
|
||||||
|
dateStep = (
|
||||||
|
<ResourceSelect resources={resources} onSelect={changeResource} />
|
||||||
|
);
|
||||||
|
} else if (serviceMode && selectedServiceUuids.length === 0) {
|
||||||
|
```
|
||||||
|
|
||||||
|
و در مرحلهٔ روز، دکمهٔ «← تغییر نوع نوبت» کنار «تغییر محل» و «تغییر سرویس» موجود در
|
||||||
|
`components/appointment/date/index.js` اضافه شود (همان سبک، همان کلاس).
|
||||||
|
|
||||||
|
**نحوه تست:** هر سه دکمهٔ بازگشت را بزن و مطمئن شو انتخابهای پاییندستی پاک میشوند — مثلاً
|
||||||
|
بعد از «تغییر نوع نوبت»، سرویس قبلی نباید هنوز انتخاب باشد.
|
||||||
|
|
||||||
|
### ۴. اسلاتها و تقویمِ منبع
|
||||||
|
|
||||||
|
در `app/component/date/dateTime/index.js` یک شاخهٔ سوم:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const req = resourceUuid
|
||||||
|
? request
|
||||||
|
.getResourceSlots(resourceUuid, dateStr, selectedServiceUuids)
|
||||||
|
.then(adaptServiceSlots)
|
||||||
|
: serviceMode
|
||||||
|
? request.getServiceSlots(...).then(adaptServiceSlots)
|
||||||
|
: request.getAppointmentSlots(...).then(adaptSlots);
|
||||||
|
```
|
||||||
|
|
||||||
|
`adaptServiceSlots` بدون تغییر کار میکند: پاسخ منبع هم `start_times` با همان شکل
|
||||||
|
`{start, end, start_time, end_time}` میدهد. **`lib/appointmentSlots.js` را دست نزن.**
|
||||||
|
|
||||||
|
`resourceUuid` باید از `Container` تا `DateTime` رد شود:
|
||||||
|
`Date` → `Hour` → `DateTime`. همان زنجیرهای که `clinicUuid` امروز طی میکند.
|
||||||
|
|
||||||
|
در `app/component/date/datePicker/index.js`، وقتی `resourceUuid` داده شده،
|
||||||
|
`getResourceMonthAvailability(resourceUuid, year, month, selectedServiceUuids)` جای
|
||||||
|
`getMonthAvailability` بنشیند. کشِ `loadedMonths` باید با تغییر `resourceUuid` هم پاک شود —
|
||||||
|
دقیقاً همان `useEffect`ی که امروز برای `clinicUuid` هست:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
useEffect(() => {
|
||||||
|
loadedMonths.current = new Set();
|
||||||
|
setDisabledSet(new Set());
|
||||||
|
setOnlineEnabled(true);
|
||||||
|
setAutoSelected(false);
|
||||||
|
}, [clinicUuid]); // ← resourceUuid هم به آرایهٔ وابستگی اضافه شود
|
||||||
|
```
|
||||||
|
|
||||||
|
**نحوه تست:** یک منبع با شیفتِ فقط دوشنبه بساز. در تقویم سایت، همهٔ روزها جز دوشنبهها باید
|
||||||
|
غیرفعال باشند. روی یک دوشنبه بزن و ساعتها را ببین.
|
||||||
|
|
||||||
|
### ۵. `resource_uuid` در payload ثبت نوبت
|
||||||
|
|
||||||
|
در `components/appointment/detail/SubmitData.js`:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// نوبتِ منبعمحور: backend مدت و slot_end را از زنجیرهٔ حلِ همین منبع بازمحاسبه
|
||||||
|
// میکند، پس عددِ کلاینت فقط پیشنهاد است.
|
||||||
|
...(resourceUuid ? { resource_uuid: resourceUuid } : {}),
|
||||||
|
```
|
||||||
|
|
||||||
|
`resourceUuid` باید از `Container` به `Detail` و از آنجا به `SubmitData` برسد — همان مسیری که
|
||||||
|
`selectedServiceUuids` و `clinicUuid` امروز طی میکنند.
|
||||||
|
|
||||||
|
مدیریت ۴۰۹: پاسخ backend در این حالت `{ success: false, errors: [{ code, message, field: 'resource_uuid' }] }`
|
||||||
|
است. پیام فارسیِ همان errors نمایش داده شود و کاربر به مرحلهٔ ساعت برگردد.
|
||||||
|
|
||||||
|
**نحوه تست (سناریوی کامل):**
|
||||||
|
۱. در پنل، منبع + سرویسِ روشن بساز.
|
||||||
|
۲. در سایت نوبت بگیر تا مرحلهٔ پرداخت.
|
||||||
|
۳. در پنل `/admin/appointments` نوبت را ببین: باید فیلد منبع را نشان دهد.
|
||||||
|
۴. برای تست ۴۰۹: همان بازه را از پنل روی همان منبع رزرو کن، بعد در تبِ سایت ثبت را بزن.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **صفر تغییر برای پزشک بدون منبع.** هر تغییری که مسیر فعلی را حتی یک کلیک عوض کند، اشتباه است.
|
||||||
|
این را با یک پزشکِ بدون منبع دستی تست کن، نه با خواندن کد.
|
||||||
|
- **طراحی جدید ممنوع.** مرحلهٔ منبع باید از کامپوننتها، کلاسها و رنگهای موجود ساخته شود.
|
||||||
|
`LocationSelect.js` مرجع ظاهر است.
|
||||||
|
- RTL، فارسی، تقویم جلالی — مثل بقیهٔ جریان. عدد مدت و قیمت با `toLocaleString("fa-IR")` مثل
|
||||||
|
`toToman()` موجود در `ServiceSelect`.
|
||||||
|
- **باطلسازیِ آبشاری** یک قاعده است نه سهتا استثنا: محل → منبع → سرویس → روز → ساعت. تغییر هر
|
||||||
|
سطح، همهٔ سطوح پایینترش را پاک میکند. اگر این را در یک تابع متمرکز کنی، جای سه `setState`
|
||||||
|
پراکنده، خطای بعدی خودش را نشان میدهد.
|
||||||
|
- **`durations[]` نفرست.** backend در مسیر عمومی آن را نمیپذیرد؛ override مدت ابزار پنل است.
|
||||||
|
- **cross-repo:** تغییر شکل پاسخ در backend اینجا خطای build نمیدهد. بعد از هر تغییر در
|
||||||
|
`clinicpro`، این سه فراخوانی را دستی بررسی کن.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# اتصال UI غنیِ نظرات/امتیاز صفحه پزشک به قرارداد جدید بکاند
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی).
|
||||||
|
|
||||||
|
> **Cross-repo:** این پرامپت به قرارداد غنیِ بکاند وابسته است که در `clinicpro/.claude/prompt/rating-multidimensional-and-rich-comments.md` تعریف شده. **آن پرامپت بکاند باید اول اجرا شود.**
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
UI غنیِ نظرات/امتیاز صفحه پزشک (چارت ۵بُعدی + دایرهی رضایت + ستارهی کلی + نظرات با نام/عکس/لایک/دیسلایک/پاسخ) از قبل در کد وجود دارد و باید **حفظ** شود. این کامپوننتها فیلدهایی میخوانند که بکاندِ سادهی قبلی نمیداد؛ حالا بکاند قرارداد غنی را تأمین میکند و فرانت فقط باید درست به آن وصل شود (و چند نقص جزئی رفع شود).
|
||||||
|
|
||||||
|
> هشدار: یک تلاش قبلی این UI را بهاشتباه «سادهسازی» کرد و سپس به HEAD برگردانده شد. UI غنی نباید ساده شود؛ فقط به قرارداد جدید وصل شود.
|
||||||
|
|
||||||
|
## قرارداد بکاند (مرجع — از پرامپت همتا)
|
||||||
|
|
||||||
|
- `GET /api/v1/rate/{uuid}` (عمومی) → `{ data: { point, satisfaction, averages:[{name,label,progress}] } }`
|
||||||
|
- `POST /api/v1/rate` (AUTH) ← `{ doctor_uuid, waiting_time_at_clinic, accuracy_of_diagnosis, doctor_behavior, clinic_cleanliness, doctor_expertise }` (هر کدام ۰–۱۰۰)
|
||||||
|
- `GET /api/v1/comments/{uuid}` (عمومی) → `{ data: { data: [ { uuid, comment, created, parent, author:{real_name,picture:[{url}]}, like_status:{like_count,dislike_count,current_user_like:{like,dislike}}, replies:[…] } ] } }`
|
||||||
|
- `POST /api/v1/comment` (AUTH) ← `{ doctor_uuid, comment, parent }`
|
||||||
|
- `POST /api/v1/like/{commentUuid}` (AUTH) ← `{ value: 1|-1 }` → `{ data: { like_count, dislike_count, current_user_like } }`
|
||||||
|
- `GET /api/v1/rate/{uuid}/eligibility` (AUTH) → `{ data: { eligible: bool } }` (قبلاً اضافه شده)
|
||||||
|
- گارد بکاند: ثبت نظر/امتیاز فقط برای کاربرِ دارای نوبت confirmed در ۳۰ روز گذشته → در غیر این صورت `403 ERR_RATING_NOT_ELIGIBLE`.
|
||||||
|
|
||||||
|
## فایلهای مرتبط (UI موجود که باید وصل شود)
|
||||||
|
|
||||||
|
| فایل | نقش | اصلاح موردنیاز |
|
||||||
|
|---|---|---|
|
||||||
|
| `app/doctor/[slug]/page.js` | fetch server-side | علاوه بر comments، تجمیع امتیاز از `GET /rate/{uuid}` را fetch کن و بهصورت prop بده |
|
||||||
|
| `components/doctor/index.js` → `detailDoctor/index.js` → `cards/comments/index.js` | زنجیره prop | `rateAggregate` (point/satisfaction/averages) را عبور بده |
|
||||||
|
| `cards/comments/chart/index.js` | چارت ۵بُعدی + ستاره | بهجای `doctor.point/satisfaction/average_rate.averages` از prop `rateAggregate` بخوان |
|
||||||
|
| `cards/comments/chart/ProgressAll.js` | دایرهی رضایت | `satisfaction` را از `rateAggregate` بگیرد |
|
||||||
|
| `cards/comments/modal/ModalAnswer.js` | مودال ثبت | پیشبارگذاری امتیاز قبلی را حذف/سازگار کن؛ eligibility بررسی شود |
|
||||||
|
| `cards/comments/modal/content/index.js` | هدر مودال | عنوان hardcoded «دکتر مهران امینی» → `doctor?.name` |
|
||||||
|
| `cards/comments/modal/content/Form.js` | ثبت امتیاز+نظر | body امتیاز را با `doctor_uuid` بفرست (نه `doctor_id`)؛ مدیریت ۴۰۳/۴۰۱ |
|
||||||
|
| `app/component/comment/ItemUser.js` | رندر نظر | `data.created`→تاریخ، author/like_status/replies حفظ؛ لایک/دیسلایک به `postCommentsLike(uuid, value)` وصل شود |
|
||||||
|
| `app/component/comment/SendAnswer.js` | ثبت پاسخ | body را با `doctor_uuid` و `parent` بفرست |
|
||||||
|
| `services/response.js` | wrappers | `postDoctorRate` بدنهی چندبُعدی؛ `postCommentsLike(uuid, value)`؛ `getRateEligibility` (موجود) |
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. fetch تجمیع امتیاز در صفحه و عبور prop
|
||||||
|
در `app/doctor/[slug]/page.js` کنار fetch فعلیِ doctor/comments، تجمیع امتیاز را بگیر:
|
||||||
|
```jsx
|
||||||
|
const resRate = await axiosInstance.get(`${API_URL}/api/v1/rate/${doctor.uuid}`);
|
||||||
|
const rateAggregate = resRate?.data?.data ?? { point: 0, satisfaction: 0, averages: [] };
|
||||||
|
```
|
||||||
|
سپس `rateAggregate` را از `DoctorPage` → `DetailDoctor` → `Comments` → `Chart`/`ProgressAll` عبور بده.
|
||||||
|
|
||||||
|
### ۲. اتصال چارت به قرارداد جدید (`chart/index.js` + `ProgressAll.js`)
|
||||||
|
- `Chart` بهجای `doctor?.average_rate?.averages` از `rateAggregate.averages` استفاده کند (همان شکل `{name,label,progress}` که `ProgressDetail` انتظار دارد).
|
||||||
|
- `امتیاز کلی کاربران: {rateAggregate.point} از ۵` و ستاره `value={rateAggregate.point}`.
|
||||||
|
- `{rateAggregate.satisfaction}% رضایت کاربران` و `ProgressAll` با `satisfaction={rateAggregate.satisfaction}`.
|
||||||
|
- ساختار/استایل و کامپوننتهای `ProgressDetail`/`ProgressAll`/loadingها حفظ شوند.
|
||||||
|
|
||||||
|
### ۳. فرم ثبت امتیاز چندبُعدی (`Form.js` + `ModalAnswer.js` + `content/index.js`)
|
||||||
|
- ساختار ۵بُعدیِ موجود (`Rates` + `ProgressChange` + `comment.rate`) حفظ شود.
|
||||||
|
- body امتیاز با **`doctor_uuid`** ارسال شود (نه `doctor_id`):
|
||||||
|
```jsx
|
||||||
|
request.postDoctorRate({
|
||||||
|
doctor_uuid: doctor.uuid,
|
||||||
|
waiting_time_at_clinic: rate[0].progress,
|
||||||
|
accuracy_of_diagnosis: rate[1].progress,
|
||||||
|
doctor_behavior: rate[2].progress,
|
||||||
|
clinic_cleanliness: rate[3].progress,
|
||||||
|
doctor_expertise: rate[4].progress,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
(ترتیب ایندکسها را با `progress_detail.json` تطبیق بده — همان مپ فعلیِ Form.)
|
||||||
|
- نظر با `doctor_uuid` ارسال شود: `postDoctorComment({ doctor_uuid: doctor.uuid, comment, parent: null })`.
|
||||||
|
- چون بکاند upsert است، `patchDoctorRate`/`defaultRate` لازم نیست؛ همیشه `postDoctorRate`.
|
||||||
|
- عنوان مودال hardcoded → `doctor?.name`.
|
||||||
|
- در `ModalAnswer`، اگر pre-load امتیاز قبلی مشکلساز است حذفش کن (بکاند endpoint «امتیاز کاربر فعلی» ندارد؛ `getDoctorRate` حالا تجمیع برمیگرداند نه رأی کاربر) — فرم با مقدار اولیهی صفر باز شود.
|
||||||
|
- مدیریت خطا: ۴۰۱ → «برای ثبت باید وارد شوید»؛ ۴۰۳ با `ERR_RATING_NOT_ELIGIBLE` → پیام واجد بودن؛ سایر → خطای عمومی. (با `alert`)
|
||||||
|
|
||||||
|
### ۴. لایک/دیسلایک واقعی (`ItemUser.js` + `services/response.js`)
|
||||||
|
- `postCommentsLike` را به `(commentUuid, value)` تغییر بده: `api.post(`api/v1/like/${uuid}`, { value }, { requireAuth: true })`.
|
||||||
|
- در `ItemUser`، هنگام کلیک لایک `value=1` و دیسلایک `value=-1` بفرست؛ از پاسخ `{like_count,dislike_count,current_user_like}` state محلی را بهروز کن (بهجای دستکاری دستیِ فعلی روی `data.like_status`).
|
||||||
|
- نمایش author/created/replies حفظ شود.
|
||||||
|
|
||||||
|
### ۵. ثبت پاسخ (`SendAnswer.js`)
|
||||||
|
- body را `{ doctor_uuid: doctor.uuid /* یا از data */, comment, parent: data.uuid }` کن (نه `doctor_id`).
|
||||||
|
- منبع `doctor.uuid` را در این کامپوننت بررسی کن؛ اگر در دسترس نیست، از prop عبور بده.
|
||||||
|
|
||||||
|
### ۶. wrapperها (`services/response.js`)
|
||||||
|
- `postDoctorRate(data)` → `POST api/v1/rate` با `requireAuth` (بدنهی چندبُعدی؛ بدون تغییر امضا، فقط دیتای جدید).
|
||||||
|
- `getRateEligibility(uuid)` (موجود) حفظ شود.
|
||||||
|
- `getDoctorRate(uuid)` حالا تجمیع برمیگرداند — مصرفکنندهها (chart) باید `data.point/...` بخوانند.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **UI غنی حفظ شود؛ ساده نکن.** فقط به قرارداد جدید وصل کن و نقصهای جزئی (hardcode، `doctor_id`→`doctor_uuid`، toggle لایک) را رفع کن.
|
||||||
|
- `app/component/comment/*` بین صفحه پزشک و بلاگ مشترک است، اما فراخوانی آن در بلاگ (`components/blog/detail/index.js`) کامنتاوت است؛ پس تغییرات صفحه پزشک بلاگ را نمیشکند — ولی هنگام تغییر `ItemUser`/`Comment` این اشتراک را در نظر بگیر و چیزی که build بلاگ را بشکند وارد نکن.
|
||||||
|
- `current_user_like` در لیست عمومی همیشه false است (طبق تصمیم بکاند)؛ وضعیت واقعی پس از کلیک از پاسخ `toggleLike` میآید — UI را optimistic نگهدار.
|
||||||
|
- نظرات نیازمند تأیید ادمیناند؛ لیست لوکال ممکن است خالی باشد → empty-state، نه خطا.
|
||||||
|
- App Router + RTL + Vazir؛ کتابخانهی جدید اضافه نکن.
|
||||||
|
- بعد از تغییر: `npm run build` بدون خطا؛ `/doctor/[slug]` و `/blog/[slug]` هر دو باید کامپایل شوند.
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
<div dir="rtl" markdown="1">
|
||||||
|
|
||||||
|
# تقسیم ممیزی SEO به پرامپتهای کوچک اجرایی — nobat724
|
||||||
|
|
||||||
|
> **وضعیت: P1 تا P11 پیاده و روی build محلی production تأیید شدهاند (هنوز deploy نشدهاند).**
|
||||||
|
> این فایل حالا **مرجع ممیزی** است، نه فهرست کار. کارهای باقیمانده به پرامپتهای کوچکتر تقسیم شدهاند — جدول «کارهای باقیمانده» پایینتر.
|
||||||
|
> بخشهای P1–P11 برای ثبت تصمیمها و یافتههای اصلی نگه داشته شدهاند.
|
||||||
|
|
||||||
|
## چکلیست کلی — انجامشده
|
||||||
|
|
||||||
|
| # | پرامپت | وضعیت | نتیجه |
|
||||||
|
| --- | --------------------------------------- | ----- | ----- |
|
||||||
|
| P1 | بررسی robots.txt | ✅ | مشکلی نبود؛ همهٔ مسیرها مجاز، sitemap معرفی شده. بدون تغییر کد |
|
||||||
|
| P2 | هلپرهای مشترک | ✅ | `lib/domainHelpers.js` (+ `findCityById`، `extractEntityCityId`) |
|
||||||
|
| P3 | رفع noindex سیستمیک لیستها | ✅ | علت واقعی: **mutate شدن شیء `searchParams` مشترک** بین بدنهٔ صفحه و `generateMetadata` — نه منطق شمارنده. `lib/listingRobots.js` |
|
||||||
|
| P4 | استراتژی canonical سهدسته + pagination | ✅ | `lib/getCanonicalUrl.js` بازنویسی شد؛ `x-search` به `proxy.js` اضافه شد؛ تناقض canonical/JSON-LD رفع شد |
|
||||||
|
| P5 | بازسازی sitemap | ✅ | معیار «claimed» → `lib/entityQuality.js`؛ **باگ کشفشده:** سقف خاموش ۵۰ رکوردی API، یاسوج ۴۸ → ۴۵۴ پزشک |
|
||||||
|
| P6 | صفحات موجودیت: 404 و noindex | ✅ | علت واقعی Soft-404: **`loading.js`** (Suspense → استریم ۲۰۰). هر سه حذف شدند |
|
||||||
|
| P7 | اصلاح H1ها | ✅ | همهٔ صفحات دقیقاً یک h1؛ کلینیک ۴ → ۱؛ Title ریشه اصلاح شد |
|
||||||
|
| P8 | یکپارچهسازی Breadcrumb | ✅ | `Pageguide` سمانتیک شد + `BreadcrumbList` از همان منبع داده |
|
||||||
|
| P9 | صفحات فرود تخصص | ✅ | `app/specialties/[slug]/` + مهاجرت ~۹۰ لینک کارت |
|
||||||
|
| P10 | بلاگ شهر-محور | ⚠️ | **کد آماده ولی غیرفعال** — API بلاگ صفر رکورد دارد و فیلد شهر ندارد. نیازمند تغییر backend |
|
||||||
|
| P11 | FAQ schema + متن مقدمه | ✅ | `FAQPage` جدا در صفحات پزشک و تخصص؛ متن مقدمهٔ یکتا با تست شمارش کلمه |
|
||||||
|
|
||||||
|
## کارهای باقیمانده — پرامپتهای کوچک
|
||||||
|
|
||||||
|
به ترتیب وابستگی. **backend اول.**
|
||||||
|
|
||||||
|
| # | پرامپت | چرا |
|
||||||
|
|---|--------|-----|
|
||||||
|
| ۱ | `clinicpro/.claude/prompt/doctors-list-city-and-limit.md` | افزودن `city` به پاسخ لیست پزشکان + شفافکردن سقف `limit` |
|
||||||
|
| ۲ | `clinicpro/.claude/prompt/blog-city-field.md` | افزودن شهر به Entity بلاگ — پیشنیاز P10 |
|
||||||
|
| ۳ | `clinicpro/.claude/prompt/cleanup-polluted-doctor-clinic-records.md` | پاکسازی رکوردهای `name = شمارهتلفن` و `"test"` + اعتبارسنجی |
|
||||||
|
| ۴ | `nobat724_front/.claude/prompt/seo-post-deploy-verification.md` | اجرای معیارهای پذیرش روی دامنههای واقعی بعد از deploy |
|
||||||
|
| ۵ | `nobat724_front/.claude/prompt/blog-city-scoping-activate.md` | فعالسازی P10 — وابسته به ۲ |
|
||||||
|
| ۶ | `nobat724_front/.claude/prompt/sitemap-simplify-with-city.md` | حذف ۳۵ sweep پرهزینه — وابسته به ۱ |
|
||||||
|
|
||||||
|
پرامپت ۴ مستقل است و میتواند بلافاصله بعد از deploy اجرا شود.
|
||||||
|
|
||||||
|
## تصمیمهای آگاهانه (side-effect نیستند)
|
||||||
|
|
||||||
|
- **M3-B:** صفحات `?page=2+` خودشان canonical دارند و ایندکسپذیر میمانند (collapse به صفحهٔ ۱ انتخاب نشد).
|
||||||
|
- **sitemap-index پیاده نشد:** ~۲٬۵۰۰ URL در هر دامنه، بسیار زیر سقف ۵۰k. جزئیات در پرامپت ۶.
|
||||||
|
- **`loading.js` سه مسیر موجودیت حذف شد:** بهای رفع Soft-404. اسکلت لودینگ هنگام ناوبری کلاینتی از دست رفت.
|
||||||
|
- **«خانه» به breadcrumb صفحهٔ `/clinics` اضافه شد:** تغییر محتوایی عمدی، همراستا با schema که از قبل روی `/clinic/[id]` بود.
|
||||||
|
- **h1 صفحهٔ اصلی روی شعار نشست** (نه یک جملهٔ کیوردی جدید): شعار هر شهر در `city.json` از قبل شهرمحور و کیورددار است.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## بلوک زمینهٔ مشترک (ابتدای هر پرامپت کپی شود)
|
||||||
|
|
||||||
|
```
|
||||||
|
## نقش
|
||||||
|
تو یک متخصص ارشد Technical SEO + مهندس Next.js 15 App Router هستی. هر تغییر با کمترین ریسک و مطابق آخرین Best Practiceهای Google Search و Next.js. فقط قابلیتهای رسمی و غیرمنسوخ Next.js. زبان خروجی و رشتهها فارسی، RTL، تقویم جلالی.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
پروژه `nobat724_front`: یک deployment واحد Next.js 15 که ۳۵ دامنهٔ شهری مستقل (data/city.json — مثل tehran-nobat.ir, yazd-nobat.ir) + دامنهٔ اصلی nobat724.com را سرو میکند. تشخیص شهر از host header (lib/getStateInfo.js سرور، context/ProvinceProvider.js کلاینت). رکورد id:600 در city.json دامنهٔ ریشه است نه شهر (lib/rootCity.js). slug پزشک/کلینیک = uuid. صفحات SSR (streaming با Suspense).
|
||||||
|
|
||||||
|
## فایلهای کلیدی
|
||||||
|
lib/getCanonicalUrl.js (تولید canonical — ریشهٔ باگ canonical) · app/sitemap.js (sitemap per-domain) · app/doctors/page.js (metadata لیست + listingRobots — باگ noindex سیستمیک) · app/clinics/page.js + components/clinics/ · app/doctor/[slug]/page.js (Physician schema + noindex برای unclaimed) · app/clinic/[slug]/page.js (canonical متناقض با JSON-LD، بدون noindex برای کلینیک خالی، چند h1) · app/specialties/page.js + components/specialties/ · app/blog/[slug]/page.js + app/blog/page.js · app/page.js + components/home/ · data/city.json + data/specialties.json + data/state.json · next.config.js (security headers — دستنخورده)
|
||||||
|
|
||||||
|
## محدودیتهای عمومی
|
||||||
|
- security headers و CSP در next.config.js را نشکن.
|
||||||
|
- هر تغییر canonical/robots را با curl روی حداقل یک دامنهٔ شهری + دامنهٔ اصلی تأیید کن.
|
||||||
|
- خارج از دامنهٔ اپ اقدام نکن (تنظیمات CDN/ArvanCloud = DevOps، فقط در گزارش یادآوری کن).
|
||||||
|
- پس از تغییر کد، `graphify update .` را اجرا کن.
|
||||||
|
- در پایان: گزارش کوتاه تغییرات + خروجی curl معیارهای پذیرش.
|
||||||
|
|
||||||
|
## محدودیتهای UI (در همهٔ پرامپتها الزامی)
|
||||||
|
- **UI سایت نباید بههم بریزد.** تغییرات SEO (تعویض تگ h1↔span/p/h2، canonical، robots، schema) نباید هیچ تغییر بصری ایجاد کنند — هنگام تعویض تگ، className و استایلهای عنصر قبلی را عیناً روی تگ جدید نگهدار.
|
||||||
|
- **صفحهٔ جدید = کامپوننتهای موجود.** برای هر صفحهٔ جدید (صفحات تخصص، breadcrumb، بلاگ) از کامپوننتهای موجود پروژه استفاده کن (کارت پزشک، لیست، دکمهها، layout و header/footer فعلی) — کامپوننت جدید فقط وقتی بساز که هیچ معادل موجودی نباشد، و در آن صورت با همان design tokenها و کلاسهای Tailwind پروژه.
|
||||||
|
- **ساختار صفحات جدید دقیقاً شبیه UI فعلی باشد.** صفحهٔ /specialties/[slug] باید از نظر چیدمان، فاصلهگذاری، تایپوگرافی و رنگ مثل /doctors فعلی به نظر برسد؛ کاربر نباید حس کند وارد بخش متفاوتی از سایت شده.
|
||||||
|
- بعد از هر تغییر، صفحات تغییریافته را از نظر بصری با نسخهٔ قبل مقایسه کن (اسکرینشات یا بازبینی دستی) و در گزارش تأیید کن که رگرسیون بصری نداریم.
|
||||||
|
|
||||||
|
## نقاط قوت فعلی (تخریب نشوند)
|
||||||
|
اسکیمای Physician/Review/Geo · هدرهای امنیتی HSTS/CSP · ریدایرکت www→non-www با 301 · صفحهٔ ۴۰۴ عمومی درست.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرجع: فهرست کامل یافتههای زندهٔ ممیزی (تأییدشده با HTML واقعی)
|
||||||
|
|
||||||
|
این بخش مرجع است؛ هر پرامپت یافتههای مرتبط خودش را جداگانه دارد، اما تصویر کامل اینجاست:
|
||||||
|
|
||||||
|
- `yazd-nobat.ir/` و `yazd-nobat.ir/doctors` → canonical به `nobat724.com` (باگ سراسری canonical).
|
||||||
|
- `yasuj-nobat.ir/doctors` **بدون هیچ query** → `robots: noindex, follow` + canonical به دامنهٔ اصلی. علت: `city`/`state` خودکار از host تزریق میشوند و شمارندهٔ `listingRobots` همیشه ۲+ میشود ⇒ صفحهٔ اصلی لیست روی **همهٔ ۳۵ دامنه همیشه noindex** است.
|
||||||
|
- `yasuj-nobat.ir/clinics` → همان دو مشکل (noindex + canonical غلط) ⇒ باگ فقط `/doctors` نیست.
|
||||||
|
- `yasuj-nobat.ir/specialties` → canonical غلط اما **بدون** noindex ⇒ `listingRobots` بین doctors/clinics مشترک است، specialties مسیر جداست. دو `<h1>` بدون نام شهر. ~۹۰ لینک کارت تخصص به `/doctors?specialty=X&city=…&state=…`.
|
||||||
|
- `yasuj-nobat.ir/clinic/9c163d69-…` → **تناقض زنده:** canonical به `nobat724.com` اما `MedicalClinic.url` و کل `BreadcrumbList` به `yasuj-nobat.ir`. کلینیک `is_active:false` با نام «09398631203» بدون noindex ایندکسپذیر (نامتقارن با پزشکان). چهار `<h1>` (نام + سه عنوان بخش). schema هست ولی breadcrumb بصری نیست (معکوس `/clinics`). Title = «09398631203 | یاسوج نوبت».
|
||||||
|
- `yasuj-nobat.ir/contact-us` → canonical به دامنهٔ اصلی، **بدون** noindex — برای صفحات استاتیک مشترک این canonical احتمالاً درست است (استثنای C1-c).
|
||||||
|
- `yasuj-nobat.ir/about-us` → canonical مشابه اما **با** noindex ⇒ robots صفحات استاتیک ناسازگار است.
|
||||||
|
- دادهٔ embedded یاسوج: `totalRecords: 456, totalPages: 38` ⇒ با query-stripping فعلی، صفحات ۲ تا ۳۸ به صفحهٔ ۱ collapse میشوند.
|
||||||
|
- HTML اولیه ۶ کارت اسکلت با `href="/doctor/undefined"` دارد ⇒ رندر streaming با Suspense است، نه SSR همزمان کامل.
|
||||||
|
- رکوردهای دادهٔ آلوده: پزشک با `name:"09390039833"` و `name:"test"` با `owner_status:"claimed"` در نتایج عمومی.
|
||||||
|
- `nobat724.com/doctors` → بدون هیچ `<h1>`. H1 صفحهٔ اصلی = «با نوبت 724». Title روت /doctors = «پزشکان نوبت 724 …» (نام رکورد root بهجای شهر).
|
||||||
|
- `/doctor/<uuid-نامعتبر>` → کد 200 (Soft-404).
|
||||||
|
- `nobat724.com/sitemap.xml` → فقط ۱۳ URL (۷ استاتیک + ۴ پزشک claimed + ۲ کلینیک + ۰ بلاگ).
|
||||||
|
- هیچ روت تمیز specialty/city و هیچ `FAQPage` schema در کل سایت نیست.
|
||||||
|
- بلاگ شهر-محور نیست با اینکه هر شهر نمایندهٔ محتوایی دارد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1 — بررسی robots.txt (پیشنیاز اعتبارسنجی)
|
||||||
|
|
||||||
|
> پیشنیاز: هیچ. اجرا: قبل از همه — اگر مسیرها مسدود باشند بقیهٔ کارها بیاثرند.
|
||||||
|
|
||||||
|
```
|
||||||
|
[بلوک زمینهٔ مشترک]
|
||||||
|
|
||||||
|
## تسک
|
||||||
|
فقط بررسی و گزارش — بدون تغییر کد مگر مشکل پیدا شود.
|
||||||
|
|
||||||
|
1. robots.txt دامنهٔ اصلی و حداقل دو دامنهٔ شهری را بگیر:
|
||||||
|
curl -s https://nobat724.com/robots.txt
|
||||||
|
curl -s https://yasuj-nobat.ir/robots.txt
|
||||||
|
curl -s https://tehran-nobat.ir/robots.txt
|
||||||
|
2. تأیید کن:
|
||||||
|
- مسیرهای /doctors, /clinics, /specialties, /blog, /doctor/, /clinic/ مسدود (Disallow) نیستند.
|
||||||
|
- sitemap.xml با خط Sitemap: معرفی شده است.
|
||||||
|
- مسیرهای پنل/لاگین (در صورت وجود) Disallow هستند.
|
||||||
|
3. اگر مسیری بهاشتباه مسدود است، فایل/روت تولیدکنندهٔ robots.txt را پیدا و اصلاح کن (احتمالاً app/robots.js یا فایل استاتیک public/robots.txt).
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
خروجی کامل robots.txt هر سه دامنه در گزارش نقل شود + جدول «مسیر × وضعیت (مجاز/مسدود)». اگر اصلاحی انجام شد، curl بعد از deploy هم ضمیمه شود.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2 — هلپرهای مشترک (زیرساخت)
|
||||||
|
|
||||||
|
> پیشنیاز: هیچ. دو تابع که چند پرامپت بعدی مصرف میکنند — دو پیادهسازی جدا ریسک واگرایی دارد، پس اول و یکبار ساخته میشوند.
|
||||||
|
|
||||||
|
```
|
||||||
|
[بلوک زمینهٔ مشترک]
|
||||||
|
|
||||||
|
## تسک
|
||||||
|
دو هلپر مشترک بساز (محل پیشنهادی: lib/domainHelpers.js یا کنار getStateInfo):
|
||||||
|
|
||||||
|
### ۱) findDomainByCityId(cityId)
|
||||||
|
- ورودی: city_id یک موجودیت (پزشک/کلینیک/پست بلاگ).
|
||||||
|
- خروجی: دامنهٔ شهری متناظر از data/city.json (مثلاً "yasuj-nobat.ir")، یا null اگر شهر دامنهٔ اختصاصی ندارد.
|
||||||
|
- رکورد id:600 (دامنهٔ ریشه) هرگز بهعنوان «شهر» برنگردد.
|
||||||
|
- مصرفکنندگان آینده: canonical موجودیتها (P4)، sitemap (P5)، بلاگ (P10).
|
||||||
|
|
||||||
|
### ۲) resolveCityDisplayName(cityInfo)
|
||||||
|
- ورودی: آبجکت شهر از getStateInfo.
|
||||||
|
- خروجی: نام قابلنمایش شهر برای H1/Title. اگر isRoot (رکورد id:600) → «ایران»، نه نام رکورد root («نوبت 724»).
|
||||||
|
- مصرفکنندگان آینده: H1 صفحات لیست (P7)، صفحات تخصص (P9).
|
||||||
|
|
||||||
|
## نکته
|
||||||
|
یافتهٔ زنده: Title روت /doctors الان «پزشکان نوبت 724 …» رندر میشود چون نام رکورد root بهجای شهر مینشیند — resolveCityDisplayName دقیقاً برای رفع این است.
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
- تست واحد یا اسکریپت کوچک: findDomainByCityId برای city_id یاسوج → "yasuj-nobat.ir"؛ برای city_id بدون دامنه → null؛ برای id:600 → null.
|
||||||
|
- resolveCityDisplayName برای رکورد root → «ایران»؛ برای یاسوج → «یاسوج».
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P3 — رفع noindex سیستمیک صفحات لیست (C5 — بدترین باگ)
|
||||||
|
|
||||||
|
> پیشنیاز: P2 (برای تشخیص «شهر host»). مهمترین فیکس تکخطینما با بیشترین اثر.
|
||||||
|
|
||||||
|
```
|
||||||
|
[بلوک زمینهٔ مشترک]
|
||||||
|
|
||||||
|
## یافتهٔ زندهٔ تأییدشده
|
||||||
|
- yasuj-nobat.ir/doctors و yasuj-nobat.ir/clinics — هر دو بدون هیچ query string — robots: noindex, follow دارند.
|
||||||
|
- علت: city/state روی سرور خودکار از host استنتاج و به شیء پارامترها تزریق میشوند؛ منطق listingRobots («۲+ پارامتر → noindex») همیشه این دو را میشمارد و همیشه ۲+ میشود.
|
||||||
|
- نتیجه: صفحات اصلی لیست پزشکان و کلینیکها روی هر ۳۵ دامنه همیشه noindex هستند — مهمترین صفحات هر دامنه برای «پزشک + شهر».
|
||||||
|
- /specialties این باگ را ندارد (مسیر جدا) — یعنی listingRobots بین /doctors و /clinics مشترک است؛ همهٔ مصرفکنندگانش را پوشش بده، نه فقط app/doctors/page.js.
|
||||||
|
- یافتهٔ مرتبط: about-us هم noindex دارد ولی contact-us ندارد — robots در کل اپ ناسازگار است؛ هنگام فیکس، همهٔ نقاط اعمال robots را یکجا فهرست و بازبینی کن.
|
||||||
|
|
||||||
|
## تسک
|
||||||
|
1. در listingRobots: پارامترهای city/state وقتی مقدارشان برابر شهر/استان استنتاجشده از host فعلی است، از شمارش «۲+ فیلتر» کاملاً حذف شوند — چه از query آمده باشند چه تزریق سمت سرور.
|
||||||
|
2. فقط فیلترهای صریحاً کاربر-انتخابشده بشمارند: تخصص، جنسیت، مرتبسازی غیرپیشفرض، city/state متفاوت از host.
|
||||||
|
3. قاعدهٔ اصلی (noindex برای جستجوی نام یا فیلتر واقعی چندگانه) حفظ شود.
|
||||||
|
4. robots صفحهٔ about-us را هم به index برگردان (صفحهٔ استاتیک نباید noindex باشد؛ canonical آن در P4 تعیین میشود).
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
curl -s https://yasuj-nobat.ir/doctors | grep -i robots # → index یا غایب
|
||||||
|
curl -s https://yasuj-nobat.ir/clinics | grep -i robots # → index یا غایب
|
||||||
|
curl -s https://tehran-nobat.ir/doctors | grep -i robots # → index یا غایب
|
||||||
|
curl -s https://yazd-nobat.ir/doctors | grep -i robots # → index یا غایب
|
||||||
|
curl -s https://yasuj-nobat.ir/about-us | grep -i robots # → index یا غایب
|
||||||
|
curl -s "https://yasuj-nobat.ir/doctors?city=تهران&specialty=X" | grep -i robots # → noindex بماند
|
||||||
|
تست حداقل روی ۳ دامنهٔ متفاوت و هر دو مسیر /doctors و /clinics — باگ سیستمیک است، یک URL کافی نیست.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P4 — استراتژی canonical سهدسته + تصمیم pagination (C1-a/b/c + M3-B)
|
||||||
|
|
||||||
|
> پیشنیاز: P2 (findDomainByCityId)، P3 (تا canonical و robots همجهت شوند). بزرگترین پرامپت — قلب فیکس.
|
||||||
|
|
||||||
|
```
|
||||||
|
[بلوک زمینهٔ مشترک]
|
||||||
|
|
||||||
|
## یافتههای زندهٔ تأییدشده
|
||||||
|
- getCanonicalUrl() در lib/getCanonicalUrl.js همهٔ صفحات روی همهٔ دامنهها را به nobat724.com canonical میکند (buildMainCanonicalUrl). تأییدشده روی: /doctors، /clinics، /specialties، /clinic/[id]، /contact-us، /about-us.
|
||||||
|
- تناقض زنده در clinic/9c163d69: canonical به nobat724.com اما MedicalClinic.url و BreadcrumbList به yasuj-nobat.ir — سیگنال متناقض به گوگل.
|
||||||
|
- pagination واقعی: یاسوج totalPages: 38 — با query-stripping فعلی همهٔ صفحات ۲+ به صفحهٔ ۱ collapse میشوند.
|
||||||
|
|
||||||
|
## قاعدهٔ سهدسته
|
||||||
|
| دسته | صفحات | canonical |
|
||||||
|
|------|-------|-----------|
|
||||||
|
| C1-a: per-domain | / ، /doctors ، /clinics ، /specialties ، /specialties/[slug] ، /blog (لیست) | self-canonical روی همان دامنه |
|
||||||
|
| C1-b: موجودیت | /doctor/[uuid] ، /clinic/[uuid] ، /blog/[slug] شهریافته | دامنهٔ شهرِ همان موجودیت (با findDomainByCityId از P2) |
|
||||||
|
| C1-c: استاتیک مشترک | /contact-us ، /about-us ، /terms ، /privacy ، … | دامنهٔ اصلی nobat724.com (استثنای عمدی — محتوا روی همهٔ دامنهها یکسان است) |
|
||||||
|
|
||||||
|
## تسک
|
||||||
|
1. getCanonicalUrl را بازنویسی کن تا searchParams هم بپذیرد (الان فقط x-pathname میخواند و query را کامل نادیده میگیرد):
|
||||||
|
- STATIC_SHARED_PAGES (فهرست C1-c، با تیم نهایی شود) → buildMainCanonicalUrl.
|
||||||
|
- /doctors با searchParams فقط specialty (تکفیلتر) → canonical به /specialties/[slug] (مقصد در P9 ساخته میشود؛ فعلاً تابع buildSpecialtyCanonicalUrl را آماده کن و پشت feature-flag یا شرط وجود روت بگذار).
|
||||||
|
- پارامتر page>1 → تصمیم M3-B: گزینهٔ پیشنهادی page را در canonical نگهدار (?page=2 خود canonical) تا صفحات ۲+ ایندکسپذیر بمانند. اگر گزینهٔ collapse به صفحهٔ ۱ انتخاب شد، در گزارش صریحاً بهعنوان تصمیم آگاهانه مستند کن، نه side-effect.
|
||||||
|
- سایر queryها (فیلتر چندگانه، sort، نام) → حذف از canonical (رفتار فعلی درست است).
|
||||||
|
- سایر مسیرها → self-canonical با host فعلی.
|
||||||
|
2. برای موجودیتها تابع getEntityCanonical(entity, pathname, currentHost) بساز:
|
||||||
|
- cityDomain = findDomainByCityId(entity?.city_id)؛ اگر null → self-canonical (fallback؛ هرگز canonical شکسته نده).
|
||||||
|
- حالتهای لبهای: پزشک چند-شهری → یک شهر اصلی (اولین/پرترافیکترین مطب) روی همهٔ دامنهها؛ شهر بدون دامنه → nobat724.com و self.
|
||||||
|
- در app/doctor/[slug]/page.js و app/clinic/[slug]/page.js اعمال کن.
|
||||||
|
3. JSON-LD را هماهنگ کن: فیلدهای url/@id در Physician و MedicalClinic و آیتمهای BreadcrumbList باید همان مقصد canonical را منعکس کنند (تناقض زندهٔ بالا را رفع و تست کن).
|
||||||
|
4. buildMainCanonicalUrl را فقط اگر جای دیگری (JSON-LD/sitemap) مصرف نمیشود حذف کن؛ وگرنه دستنخورده.
|
||||||
|
5. گزینهٔ 301 بهجای canonical برای موجودیتها را فقط در گزارش پیشنهاد بده (عیبها: redirect chain بکلینکها، پرش دامنه در UX پورتال). بدون تأیید صریح پیاده نکن.
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
# C1-a
|
||||||
|
curl -s https://yazd-nobat.ir/doctors | grep canonical # → yazd-nobat.ir/doctors
|
||||||
|
curl -s https://nobat724.com/doctors | grep canonical # → nobat724.com/doctors
|
||||||
|
curl -s https://yasuj-nobat.ir/specialties | grep canonical # → yasuj-nobat.ir/specialties
|
||||||
|
# C1-b
|
||||||
|
curl -s https://yasuj-nobat.ir/doctor/beaca548-816f-4613-937d-360db01bd7c8 | grep canonical # → self
|
||||||
|
curl -s https://nobat724.com/doctor/beaca548-816f-4613-937d-360db01bd7c8 | grep canonical # → yasuj-nobat.ir
|
||||||
|
curl -s https://yasuj-nobat.ir/clinic/9c163d69-0051-4745-a423-c830135b1c01 | grep canonical # → self (شهر=یاسوج)
|
||||||
|
# C1-c
|
||||||
|
curl -s https://yasuj-nobat.ir/contact-us | grep canonical # → nobat724.com/contact-us
|
||||||
|
curl -s https://yasuj-nobat.ir/about-us | grep canonical # → nobat724.com/about-us
|
||||||
|
# M3-B (اگر گزینهٔ پیشنهادی)
|
||||||
|
curl -s "https://yasuj-nobat.ir/doctors?page=2" | grep canonical # → …/doctors?page=2
|
||||||
|
# هماهنگی JSON-LD
|
||||||
|
curl -s https://yasuj-nobat.ir/clinic/9c163d69-… | grep -o '"url":"[^"]*"' # → همان دامنهٔ canonical
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P5 — بازسازی sitemap (C2)
|
||||||
|
|
||||||
|
> پیشنیاز: P4 (sitemap باید فقط URLهای canonical را سابمیت کند).
|
||||||
|
|
||||||
|
```
|
||||||
|
[بلوک زمینهٔ مشترک]
|
||||||
|
|
||||||
|
## یافتهٔ زنده
|
||||||
|
nobat724.com/sitemap.xml فقط ۱۳ URL دارد (۷ استاتیک + ۴ پزشک + ۲ کلینیک + ۰ بلاگ). علت: فیلتر owner_status === 'claimed' در getDoctorUrls (app/sitemap.js). یک مارکتپلیس عملاً ۴ صفحهٔ پزشک ایندکسپذیر دارد.
|
||||||
|
|
||||||
|
## تسک
|
||||||
|
1. معیار ورود به sitemap: از «claimed» به «دارای حداقل دادهٔ معنادار» (uuid + حداقل یک تخصص یا آدرس/شهر).
|
||||||
|
2. در app/doctor/[slug]/page.js شرط isUnclaimed → noindex را نرم کن: فقط پزشکان بدون هیچ محتوای معنادار noindex بمانند.
|
||||||
|
3. هماهنگی با canonical (P4): getDoctorUrls از همان findDomainByCityId استفاده کند. sitemap هر دامنه فقط موجودیتهایی را شامل شود که canonical آنها همان دامنه است. sitemap دامنهٔ اصلی فقط موجودیتهای بدون دامنهٔ شهری.
|
||||||
|
4. برای مقیاس بالای ~۵۰k URL، sitemap-index با فایلهای جدا (doctors/clinics/blogs/static) بساز.
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
curl -s https://nobat724.com/sitemap.xml | grep -c "<loc>" # بسیار بیشتر از ۱۳
|
||||||
|
curl -s https://nobat724.com/sitemap.xml | grep "beaca548-816f-4613-937d-360db01bd7c8" # → خالی (canonical این پزشک یاسوج است)
|
||||||
|
curl -s https://yasuj-nobat.ir/sitemap.xml | grep "beaca548-816f-4613-937d-360db01bd7c8" # → موجود
|
||||||
|
پزشک معتبر unclaimed دیگر noindex نگیرد (curl روی یک نمونه).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P6 — صفحات موجودیت: Soft-404 و noindex کلینیک خالی (H4 + H7)
|
||||||
|
|
||||||
|
> پیشنیاز: P4 (تا سیاست robots/canonical موجودیتها معلوم باشد). دو فیکس کوچک همحوزه.
|
||||||
|
|
||||||
|
```
|
||||||
|
[بلوک زمینهٔ مشترک]
|
||||||
|
|
||||||
|
## یافتههای زنده
|
||||||
|
- /doctor/<uuid-نامعتبر> کد 200 میدهد (Soft-404) — API برای uuid ناموجود 200 با data:null برمیگرداند و کد به notFound() نمیرسد.
|
||||||
|
- clinic/9c163d69 با is_active:false، نام «09398631203» (شمارهتلفن)، بدون caption/logo/services — کاملاً ایندکسپذیر است. منطق «thin → noindex» که برای پزشک unclaimed هست، برای کلینیک اصلاً وجود ندارد (نامتقارن).
|
||||||
|
|
||||||
|
## تسک
|
||||||
|
### H4 — Soft-404
|
||||||
|
1. در getDoctor (app/doctor/[slug]/page.js): null-check کامل روی json?.data?.data → notFound().
|
||||||
|
2. همان الگو برای app/clinic/[slug]/page.js و app/blog/[slug]/page.js.
|
||||||
|
(روت /specialties/[slug] هنوز ساخته نشده — در P9 همین الگو اعمال میشود.)
|
||||||
|
|
||||||
|
### H7 — noindex کلینیک خالی
|
||||||
|
1. در app/clinic/[slug]/page.js: کلینیک با is_active:false یا بدون محتوای معنادار (نام واقعی/خدمات/توضیح) → noindex.
|
||||||
|
2. تصمیم بگیر و مستند کن: is_active:false یعنی «حذفشده» (→ 404/410) یا «موقتاً غیرفعال» (→ noindex)؟
|
||||||
|
3. هماهنگی با P5: موجودیت noindex نباید در sitemap باشد.
|
||||||
|
4. در گزارش برای تیم عملیات: رکوردهای آلوده (نام = شمارهتلفن، name:"test" با owner_status:"claimed") باید در لایهٔ داده پاک شوند — خارج از کد.
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
curl -o /dev/null -w "%{http_code}" https://nobat724.com/doctor/invalid-xxx # → 404
|
||||||
|
curl -o /dev/null -w "%{http_code}" https://nobat724.com/clinic/invalid-xxx # → 404
|
||||||
|
curl -s https://yasuj-nobat.ir/clinic/9c163d69-0051-4745-a423-c830135b1c01 | grep -i robots # → noindex (یا 404/410 طبق تصمیم)
|
||||||
|
curl -s https://yasuj-nobat.ir/sitemap.xml | grep "9c163d69" # → خالی
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P7 — اصلاح H1ها در همهٔ صفحات (H2 + H3)
|
||||||
|
|
||||||
|
> پیشنیاز: P2 (resolveCityDisplayName). مستقل از canonical — میتواند موازی P4-P6 اجرا شود.
|
||||||
|
|
||||||
|
```
|
||||||
|
[بلوک زمینهٔ مشترک]
|
||||||
|
|
||||||
|
## یافتههای زنده (الگوی مشترک: Title/meta شهرمحور درستاند، H1 از منبع جدا و استاتیک میآید)
|
||||||
|
| صفحه | H1 فعلی | مشکل |
|
||||||
|
|------|---------|-------|
|
||||||
|
| / (اصلی) | «با نوبت 724» | برندمحور، بدون کیورد |
|
||||||
|
| /doctors | «کهگیلویه و بویراحمد / یاسوج» | متن دکمهٔ انتخاب موقعیت داخل <button> — h1 نیست |
|
||||||
|
| /specialties | دو h1: «تخصص مورد نظر شما چیست؟» + «لیست تخصص ها» | دو h1، بدون نام شهر |
|
||||||
|
| /clinics | «لیست کلینیک ها و درمانگاه های تخصصی» | استاتیک، بدون نام شهر |
|
||||||
|
| /clinic/[id] | چهار h1: نام + «بیمه های طرف قرارداد» + «تخصص ها» + «خدمات» | عنوان بخشها با h1 |
|
||||||
|
| Title روت /doctors | «پزشکان نوبت 724 …» | نام رکورد root بهجای شهر |
|
||||||
|
|
||||||
|
## تسک
|
||||||
|
1. صفحهٔ اصلی: H1 → «نوبتدهی آنلاین پزشکان و کلینیکهای سراسر ایران» (برند جای دیگر).
|
||||||
|
2. /doctors: عنصر دکمه از h1 به span/p؛ h1 واقعی بالای لیست: «پزشکان و متخصصان {resolveCityDisplayName} | رزرو نوبت آنلاین» (components/doctors/).
|
||||||
|
3. /specialties: «تخصص مورد نظر شما چیست؟» غیرعنوانی شود؛ h1 یکتا: «لیست تخصصهای پزشکی در {resolveCityDisplayName}».
|
||||||
|
4. /clinics: h1 پویا: «کلینیکها و مراکز درمانی {resolveCityDisplayName}».
|
||||||
|
5. /clinic/[id]: فقط نام کلینیک h1؛ سه عنوان بخش → h2. همین را در /doctor/[id] هم بررسی و در صورت وجود اصلاح کن.
|
||||||
|
6. همهٔ الگوهای «پزشکان {cityName}» از resolveCityDisplayName استفاده کنند (رفع Title روت).
|
||||||
|
7. هر صفحه دقیقاً یک h1 (نه صفر، نه چند).
|
||||||
|
8. **بدون تغییر بصری:** هر تعویض تگ (h1→span، h1→h2) className و استایل عنصر قبلی را عیناً حفظ کند؛ h1 جدیدی که اضافه میشود با تایپوگرافی موجود صفحه هماهنگ باشد. بعد از تغییر، ظاهر صفحات با قبل مقایسه و در گزارش تأیید شود.
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
for p in doctors specialties clinics; do
|
||||||
|
curl -s "https://yasuj-nobat.ir/$p" | grep -c "<h1" # → 1
|
||||||
|
curl -s "https://yasuj-nobat.ir/$p" | grep -o "<h1[^>]*>[^<]*" # → شامل «یاسوج»
|
||||||
|
done
|
||||||
|
curl -s https://yasuj-nobat.ir/clinic/9c163d69-… | grep -c "<h1" # → 1 (فعلاً 4)
|
||||||
|
curl -s https://nobat724.com/ | grep -o "<h1[^>]*>[^<]*" # → کیورددار، نه «با نوبت 724»
|
||||||
|
curl -s https://nobat724.com/doctors | grep -o "<title>[^<]*" # → بدون تکرار «نوبت 724» بهجای شهر
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P8 — یکپارچهسازی Breadcrumb (H6)
|
||||||
|
|
||||||
|
> پیشنیاز: P4 (URLهای schema باید با canonical همجهت باشند)، P7 (h2های اشتباه همانجا پاک میشوند).
|
||||||
|
|
||||||
|
```
|
||||||
|
[بلوک زمینهٔ مشترک]
|
||||||
|
|
||||||
|
## یافتههای زنده (دو نیمهٔ ناقص یک قابلیت)
|
||||||
|
- /clinics: breadcrumb بصری با تگ <h2> داخل <ul> ساخته شده («کلینیک ها > یاسوج»)، بدون لینک <a>، بدون BreadcrumbList schema.
|
||||||
|
- /clinic/[id]: دقیقاً برعکس — BreadcrumbList JSON-LD دارد (به yasuj-nobat.ir اشاره میکند) اما هیچ breadcrumb بصری ندارد.
|
||||||
|
|
||||||
|
## تسک
|
||||||
|
1. یک کامپوننت Breadcrumb واحد بساز: <nav aria-label="breadcrumb"> + <ol> + <a> (آیتم فعلی <span>). هیچ h2ای. **ظاهرش دقیقاً همان breadcrumb فعلی /clinics باشد** (همان کلاسهای Tailwind، فاصلهها، جداکنندهٔ «>») — فقط تگها و ساختار سمانتیک عوض میشود، نه شکل بصری.
|
||||||
|
2. BreadcrumbList JSON-LD همراه همان کامپوننت (script مستقل، با safeJsonLd موجود) — یک منبع داده برای بصری و schema تا هرگز واگرا نشوند.
|
||||||
|
3. روی /clinics و /clinic/[id] اعمال کن؛ /doctors و /specialties و /doctor/[id] را هم بررسی و در صورت نیاز اضافه کن.
|
||||||
|
4. URLهای آیتمها با مقصد canonical صفحه (P4) همجهت باشند.
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
curl -s https://yasuj-nobat.ir/clinics | grep -o "<h2[^>]*>[^<]*" # → بدون «کلینیک ها»/«یاسوج»
|
||||||
|
curl -s https://yasuj-nobat.ir/clinics | grep "BreadcrumbList" # → موجود
|
||||||
|
curl -s https://yasuj-nobat.ir/clinic/9c163d69-… | grep -c "aria-label=\"breadcrumb\"" # → 1 (بصری اضافه شد)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P9 — صفحات فرود تخصص و «تخصص + شهر» (C3 — بزرگترین فرصت رشد)
|
||||||
|
|
||||||
|
> پیشنیاز: P4 (canonical و buildSpecialtyCanonicalUrl)، P5 (sitemap)، P7 (resolveCityDisplayName و الگوی h1).
|
||||||
|
|
||||||
|
```
|
||||||
|
[بلوک زمینهٔ مشترک]
|
||||||
|
|
||||||
|
## یافتهٔ زنده
|
||||||
|
- کیوردهای پرارزش («متخصص پوست تهران») هیچ صفحهٔ فرودی ندارند؛ فقط /doctors?specialty=… (کوئری فارسی).
|
||||||
|
- صفحهٔ /specialties با ~۹۰ کارت، همه به /doctors?specialty=X&city=یاسوج&state=… لینک میدهند — همان الگویی که پیش از P3 باعث noindex میشد؛ این ۹۰ لینک باید مقصد تمیز بگیرند.
|
||||||
|
|
||||||
|
## تسک
|
||||||
|
1. روت app/specialties/[slug]/page.js بساز — slug انگلیسی/تمیز از data/specialties.json. روی هر دامنهٔ شهری خودکار «تخصص + آن شهر» میشود (شهر از host). **این صفحه از کامپوننتهای موجود ساخته شود** — همان کارت پزشک، همان layout و header/footer، و همان کامپوننت لیستی که /doctors استفاده میکند؛ چیدمان و ظاهر نهایی باید از نظر کاربر دقیقاً همخانوادهٔ /doctors فعلی باشد. هیچ کامپوننت/استایل جدیدی نساز مگر معادل موجود نداشته باشد.
|
||||||
|
2. هر صفحه شامل:
|
||||||
|
- h1: «متخصص {specialty} در {resolveCityDisplayName}».
|
||||||
|
- پاراگراف مقدمهٔ یکتا (تعداد پزشک، میانگین امتیاز شهر، توضیح خدمت).
|
||||||
|
- لیست پزشکان SSR (/api/v1/doctors با فیلتر تخصص/شهر).
|
||||||
|
- بلوک FAQ + FAQPage schema (script مستقل).
|
||||||
|
- کامپوننت Breadcrumb از P8 + ItemList/MedicalWebPage.
|
||||||
|
- generateMetadata کیوردمحور، canonical self-domain (طبق C1-a در P4).
|
||||||
|
3. slug نامعتبر → notFound() (الگوی P6).
|
||||||
|
4. این URLها به sitemap اضافه شوند (P5): ضرب specialties.json × دامنهها.
|
||||||
|
5. لینکهای داخلی: کارتهای /specialties و فوتر → روت جدید. Breadcrumb صفحهٔ پزشک از /doctors?specialty= به URL تمیز.
|
||||||
|
6. فعالسازی canonical کوئری قدیم (آمادهشده در P4): /doctors?specialty=X تکفیلتر → canonical به /specialties/[slug].
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
curl -s https://tehran-nobat.ir/specialties/dermatology | grep -c "<h1>" # → 1 (شامل نام شهر)
|
||||||
|
curl -s https://nobat724.com/sitemap.xml | grep "specialties/" # → موجود
|
||||||
|
curl -s "https://tehran-nobat.ir/doctors?specialty=dermatology" | grep canonical # → …/specialties/dermatology
|
||||||
|
curl -o /dev/null -w "%{http_code}" https://tehran-nobat.ir/specialties/invalid-slug # → 404
|
||||||
|
curl -s https://yasuj-nobat.ir/specialties | grep -c "doctors?specialty=" # → 0 (لینکها مهاجرت کردهاند)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P10 — بلاگ شهر-محور (C4)
|
||||||
|
|
||||||
|
> پیشنیاز: P4 (الگوی canonical موجودیت)، P5 (sitemap). ممکن است نیاز به تغییر بکاند/مدل داده داشته باشد — اول بررسی، بعد کد.
|
||||||
|
|
||||||
|
```
|
||||||
|
[بلوک زمینهٔ مشترک]
|
||||||
|
|
||||||
|
## یافتهٔ زنده
|
||||||
|
sitemap صفر URL بلاگ دارد. هر شهر نمایندهٔ اختصاصی دارد اما ساختار بلاگ ارتباط پست با شهر را در URL/عنوان/متادیتا منعکس نمیکند.
|
||||||
|
|
||||||
|
## پیش از کد، بررسی کن (اسکیمای دادهٔ بلاگ مستند نیست)
|
||||||
|
- آیا پست بلاگ فیلد شهر (city_id یا مشابه) دارد؟ اگر نه، افزودنش قدم اول است (با امکان «سراسری» برای پستهای عمومی).
|
||||||
|
- آیا نویسنده/نماینده به شهر مشخصی مرتبط است که بتوان از آن استنتاج کرد؟
|
||||||
|
|
||||||
|
## تسک
|
||||||
|
1. نمایش صریح شهر در HTML قابلمشاهده (نه فقط متادیتا): عنوان/زیرعنوان مثل «{title} | مخصوص {cityName}» یا بلوک «این مطلب برای شهر {cityName} نوشته شده». این عنصر با استایل موجود صفحهٔ بلاگ هماهنگ باشد (مثلاً همان کامپوننت badge/برچسبی که در پروژه هست) — عنصر بصری ناهماهنگ با بقیهٔ صفحه اضافه نکن.
|
||||||
|
2. canonical طبق الگوی موجودیت (P4): پست شهریافته → دامنهٔ همان شهر (با همان findDomainByCityId — تابع مشترک، نه پیادهسازی جدا). پست سراسری → self روی دامنهٔ اصلی.
|
||||||
|
3. لیست بلاگ per-domain: app/blog/page.js روی هر دامنهٔ شهری = پستهای آن شهر + سراسری؛ خود لیست self-canonical (C1-a).
|
||||||
|
4. sitemap (P5): هر پست فقط در sitemap دامنهٔ canonical خودش.
|
||||||
|
5. Soft-404 (الگوی P6): بعد از افزودن فیلد شهر، null-check همچنان درست باشد.
|
||||||
|
6. JSON-LD: BlogPosting/Article با areaServed یا spatialCoverage (script مستقل).
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
curl -s https://nobat724.com/sitemap.xml | grep -c "/blog/" # → بیشتر از ۰
|
||||||
|
curl -s https://yasuj-nobat.ir/blog/<slug-پست-یاسوجی> | grep -i "یاسوج" # → نام شهر در HTML
|
||||||
|
curl -s https://yasuj-nobat.ir/blog/<slug-پست-یاسوجی> | grep canonical # → self (دامنهٔ شهر)
|
||||||
|
curl -s https://nobat724.com/blog/<slug-پست-یاسوجی> | grep canonical # → yasuj-nobat.ir (نه self)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P11 — FAQPage schema و متن مقدمهٔ یکتا (M1 + M2)
|
||||||
|
|
||||||
|
> پیشنیاز: P9 (صفحات تخصص باید وجود داشته باشند). آخرین لایه — بهبود، نه رفع باگ.
|
||||||
|
|
||||||
|
```
|
||||||
|
[بلوک زمینهٔ مشترک]
|
||||||
|
|
||||||
|
## تسک
|
||||||
|
### M1 — FAQPage schema
|
||||||
|
1. بلوک «سوالات متداول» + FAQPage JSON-LD به صفحات پزشک، تخصص و شهر.
|
||||||
|
2. FAQPage حتماً یک <script type="application/ld+json"> مستقل باشد (نه ادغام در @graph بدون تست) — schemaهای موجود Physician/Breadcrumb/Review/Geo نقطهٔ قوتاند و نباید بشکنند. از safeJsonLd موجود استفاده کن.
|
||||||
|
|
||||||
|
### M2 — متن مقدمهٔ یکتا
|
||||||
|
بالای /doctors و /clinics و صفحات تخصص: ۱۵۰–۳۰۰ کلمه متن یکتای شهری/تخصصی (رفع Thin Content — مکمل canonical؛ محتوای یکسان با canonical درست هنوز duplicate است).
|
||||||
|
نکته: متنها الگوی خام «{کار} کنید، {کار} کنید» نداشته باشند؛ برای هر شهر/تخصص ساختار جمله متفاوت باشد.
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
curl -s https://yasuj-nobat.ir/specialties/dermatology | grep "FAQPage" # → موجود
|
||||||
|
Rich Results Test گوگل روی یک صفحهٔ پزشک: schemaهای قبلی سالم + FAQPage معتبر.
|
||||||
|
شمارش کلمات بلوک مقدمه در ۳ شهر مختلف: بین ۱۵۰ تا ۳۰۰ و متنها یکسان نباشند.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## یادداشتهای سراسری (در گزارش نهایی هر فاز تکرار شود)
|
||||||
|
|
||||||
|
- **کیفیت داده (خارج از کد):** رکوردهای پزشک/کلینیک با name = شمارهتلفن یا "test" با owner_status:"claimed" باید توسط تیم عملیات پاکسازی شوند.
|
||||||
|
- **DevOps:** بررسی bot-challenge ArvanCloud روی دامنهٔ اصلی — کد نیست، فقط یادآوری.
|
||||||
|
- **پس از هر deploy مؤثر بر canonical/robots:** در Search Console هر دامنهٔ درگیر، URL Inspection روی صفحات کلیدی + انتظار چند هفته برای re-index.
|
||||||
|
- **SSR:** رندر لیستها streaming با Suspense است (۶ کارت اسکلت با href="/doctor/undefined" در HTML اولیه). فعلاً مشکلساز نیست اما در گزارش ذکر شود؛ اگر فرصت شد href اسکلتها خالی/بدون لینک شود.
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
# بازبینی کامل Rendering Strategy، SEO، Performance و امنیت (Next.js 15)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
این سایت عمومی نوبتدهی (App Router، چند-شهری، RTL فارسی) چندین مشکل ساختاری دارد که هم روی SEO و هم روی performance اثر میگذارد:
|
||||||
|
|
||||||
|
1. تمام fetchهای server-side با **axios** انجام میشوند (`lib/req.js` → `fetchReq`/`axiosInstance`)، نه `fetch` بومی Next.js. این یعنی **هیچکدام از قابلیتهای `cache`/`revalidate`/`force-cache`/`no-store` Next.js کار نمیکنند** — همهچیز عملاً همیشه SSR بدون cache است، حتی صفحاتی که میتوانند ISR باشند (مثل `/doctor/[slug]`).
|
||||||
|
2. `middleware.js` روی همه مسیرها (`matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)'`) اجرا میشود تا فقط یک هدر (`x-pathname`) ست کند — این کار رندر استاتیک را در سطح کل سایت بهصورت اجباری به dynamic تبدیل میکند.
|
||||||
|
3. در `app/doctor/[slug]/page.js`، `generateMetadata` و کامپوننت `Doctor()` هر دو مستقل `GET /api/v1/doctor/${slug}` را صدا میزنند — یعنی هر بار بازدید صفحه پزشک، **۲ بار درخواست یکسان** به backend میرود (هیچ dedup با `React.cache()` وجود ندارد چون axios است نه fetch).
|
||||||
|
4. هیچ `loading.js`, `error.js`, `template.js` در کل `app/` وجود ندارد — یعنی هیچ Suspense boundary یا error boundary واقعی در سطح route نیست؛ خطاهای fetch با `catch` خاموش میشوند و صفحه با داده `null` رندر میشود.
|
||||||
|
5. `og:image`/`twitter:image` در بسیاری صفحات به یک لوگوی استاتیک ثابت (`https://www.nobat724.com/assets/images/logo.png`) فالبک میکنند یا اصلاً ست نمیشوند (`app/doctors/page.js` فقط `title`/`description` در `openGraph` دارد، بدون `images`).
|
||||||
|
6. JSON-LD فقط در `doctor/[slug]`، `clinic/[slug]`، `blog/[slug]` هست؛ هیچ `Organization`, `WebSite`, `BreadcrumbList` در سطح global (`layout.js`) وجود ندارد.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `lib/req.js` | `fetchReq`/`axiosInstance` — تمام server fetchها از اینجا رد میشوند |
|
||||||
|
| `middleware.js` | روی همه مسیرها اجرا میشود، رندر دینامیک سراسری تحمیل میکند |
|
||||||
|
| `app/layout.js` | `generateMetadata` ریشه؛ بدون JSON-LD سراسری (Organization/WebSite) |
|
||||||
|
| `app/doctors/page.js` | لیست پزشکان؛ SSR کامل، بدون cache/revalidate، بدون `og:images` |
|
||||||
|
| `app/doctor/[slug]/page.js` | دو فراخوانی تکراری به همان endpoint؛ JSON-LD ناقص (بدون `@id`, `url`) |
|
||||||
|
| `app/clinic/[slug]/page.js`, `app/blog/[slug]/page.js` | الگوی مشابه `doctor/[slug]` — باید با همان منطق بررسی شوند |
|
||||||
|
| `app/specialties/page.js`, `app/about-us/page.js`, `app/blogs/page.js`, `app/clinics/page.js` | از `getStateInfo()` برای متادیتا استفاده میکنند؛ محتوای نسبتاً ایستا اما بهصورت SSR رندر میشوند |
|
||||||
|
| `app/dashboard/page.js`, `app/panel/(layout)/layout.js` | پنل کاربری احرازشده — اینها باید SSR/CSR بمانند (داده per-user) |
|
||||||
|
| `lib/getStateInfo.js` | تشخیص شهر از subdomain — روی هر درخواست header میخواند، نمیتواند cache شود مگر با segment config درست |
|
||||||
|
| `app/sitemap.js`, `app/robots.js` | باید بررسی شوند که `revalidate` و فیلتر صفحات DEV_MODE درست تنظیم شده باشد |
|
||||||
|
| `app/globals.css`, `mui/index.js` | فونت Vazir، بررسی `next/font` بهجای `@font-face` دستی |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
### `lib/req.js` — مشکل اصلی caching
|
||||||
|
|
||||||
|
```js
|
||||||
|
import axios from "axios";
|
||||||
|
import https from "https";
|
||||||
|
|
||||||
|
export const axiosInstance = axios.create({
|
||||||
|
...(process.env.NODE_ENV === "development" && {
|
||||||
|
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchReq = async (url, headers) => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get(url, headers);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("fetchReq error:", error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### `app/doctor/[slug]/page.js` — دو فراخوانی تکراری
|
||||||
|
|
||||||
|
```js
|
||||||
|
export async function generateMetadata({ params }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
// ...
|
||||||
|
const res = await axiosInstance.get(`${API_URL}/api/v1/doctor/${slug}`);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
async function Doctor({ params }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
// ...
|
||||||
|
const resDoctor = await axiosInstance.get(`${API_URL}/api/v1/doctor/${slug}`); // همان درخواست، دوباره
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `middleware.js` — اجرا روی همه مسیرها
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function middleware(request) {
|
||||||
|
const response = NextResponse.next();
|
||||||
|
response.headers.set('x-pathname', request.nextUrl.pathname);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### `app/doctors/page.js` — بدون og:images، بدون cache
|
||||||
|
|
||||||
|
```js
|
||||||
|
export async function generateMetadata() {
|
||||||
|
// ...
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: { title, description }, // بدون images
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function Doctors({ searchParams }) {
|
||||||
|
// ...
|
||||||
|
doctors = await fetchReq(`${API_URL}/api/v1/doctors`, { params }); // هر بار fresh، بدون revalidate
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. جایگزینی fetch لایهی axios با `fetch` بومی Next.js (یا wrapper روی آن) برای فراخوانیهای Server Component
|
||||||
|
|
||||||
|
برای صفحاتی که قابل ISR هستند (`doctor/[slug]`, `clinic/[slug]`, `blog/[slug]`, `specialties`, `about-us`)، بهجای `axiosInstance.get`/`fetchReq` از `fetch` با `next.revalidate` استفاده کن:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function getDoctor(slug) {
|
||||||
|
const res = await fetch(`${API_URL}/api/v1/doctor/${slug}`, {
|
||||||
|
next: { revalidate: 3600, tags: [`doctor-${slug}`] },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const json = await res.json();
|
||||||
|
return json?.data?.data;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- برای صفحاتی که داده per-user/per-session دارند (`dashboard`, `panel/*`, `appointment/[doctorId]` در حالت لاگینشده) از `cache: 'no-store'` یا اصلاً تغییری در رویکرد SSR فعلی نده.
|
||||||
|
- چون مسیر axios هنوز برای client-side (`services/api.js`/`services/response.js`) لازم است، **این تغییر را فقط در فایلهای Server Component (`app/.../page.js`) اعمال کن** — axios در client services دست نخورد.
|
||||||
|
|
||||||
|
### ۲. حذف فراخوانی تکراری در `doctor/[slug]/page.js` (و الگوی مشابه در `clinic/[slug]`, `blog/[slug]`)
|
||||||
|
|
||||||
|
از `React.cache()` برای dedup بین `generateMetadata` و کامپوننت صفحه استفاده کن:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
|
const getDoctor = cache(async (slug) => {
|
||||||
|
const res = await fetch(`${API_URL}/api/v1/doctor/${slug}`, {
|
||||||
|
next: { revalidate: 3600 },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const json = await res.json();
|
||||||
|
return json?.data?.data ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const doctor = await getDoctor(slug);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
async function Doctor({ params }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const doctor = await getDoctor(slug); // همان نتیجه cacheشده، بدون درخواست دوم
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
بررسی کن همین الگو در `app/clinic/[slug]/page.js` و `app/blog/[slug]/page.js` هم تکرار شده یا نه و در صورت وجود اصلاح کن.
|
||||||
|
|
||||||
|
### ۳. بازبینی `middleware.js` — محدود کردن matcher یا حذف وابستگی غیرضروری
|
||||||
|
|
||||||
|
اگر `x-pathname` فقط برای `getCanonicalUrl()` لازم است، بررسی کن آیا میتوان canonical را بدون middleware (مثلاً از `headers()` در خود `generateMetadata` با `request.url` معادل App Router، یا با محاسبه از `params`/segment) ساخت. اگر middleware واقعاً لازم است، **matcher را به مسیرهایی که واقعاً به canonical نیاز دارند محدود کن** (نه همهی سایت):
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
'/doctor/:path*',
|
||||||
|
'/clinic/:path*',
|
||||||
|
'/blog/:path*',
|
||||||
|
'/doctors',
|
||||||
|
'/clinics',
|
||||||
|
'/blogs',
|
||||||
|
'/specialties',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
مستندسازی کن که این تغییر چه صفحاتی را از حالت force-dynamic خارج میکند.
|
||||||
|
|
||||||
|
### ۴. افزودن JSON-LD سراسری در `app/layout.js`
|
||||||
|
|
||||||
|
`Organization` و `WebSite` schema را یکبار در ریشه اضافه کن (نه در هر صفحه):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const orgJsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
name: matchedCity?.site_name || "نوبت 724",
|
||||||
|
url: "https://www.nobat724.com",
|
||||||
|
logo: "https://www.nobat724.com/assets/images/logo.png",
|
||||||
|
};
|
||||||
|
const websiteJsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
url: "https://www.nobat724.com",
|
||||||
|
potentialAction: {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
target: "https://www.nobat724.com/doctors?search={search_term_string}",
|
||||||
|
"query-input": "required name=search_term_string",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
و `BreadcrumbList` در صفحات تکآیتمی (`doctor/[slug]`, `clinic/[slug]`, `blog/[slug]`) کنار JSON-LD موجود اضافه کن:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const breadcrumbJsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
itemListElement: [
|
||||||
|
{ "@type": "ListItem", position: 1, name: "خانه", item: "https://www.nobat724.com" },
|
||||||
|
{ "@type": "ListItem", position: 2, name: "پزشکان", item: "https://www.nobat724.com/doctors" },
|
||||||
|
{ "@type": "ListItem", position: 3, name: `دکتر ${doctor.name}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۵. تکمیل og:image/twitter:image در همه صفحات
|
||||||
|
|
||||||
|
- `app/doctors/page.js`: اضافه کن `images: ["https://www.nobat724.com/assets/images/logo.png"]` (یا تصویر مرتبطتر اگر موجود است) به `openGraph` و `twitter`.
|
||||||
|
- `app/doctor/[slug]/page.js`: مقدار `doctor.img` را قبل از استفاده در `images` با `imageUrl()` (از `helper/index.js`) absolute کن — همان helper که در کار قبلی avatar استفاده شد — چون ممکن است relative path باشد و در og:image کرول نشود:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { imageUrl } from "@/helper";
|
||||||
|
// ...
|
||||||
|
images: [imageUrl(doctor.img) || "https://www.nobat724.com/assets/images/logo.png"],
|
||||||
|
```
|
||||||
|
|
||||||
|
- همین بررسی را برای `app/clinic/[slug]/page.js` و `app/blog/[slug]/page.js` انجام بده.
|
||||||
|
|
||||||
|
### ۶. اضافه کردن `loading.js` برای مسیرهای دادهمحور
|
||||||
|
|
||||||
|
برای `app/doctors/`, `app/doctor/[slug]/`, `app/clinics/`, `app/clinic/[slug]/`, `app/blogs/`, `app/blog/[slug]/` یک `loading.js` با اسکلت متناسب با `CircularLoading`/`TextLoading`/`CustomLoading` موجود در `app/component/loading/` بساز (این کامپوننتها همین الان هم بهصورت دستی در صفحات استفاده میشوند؛ هدف اینجا یک Suspense boundary واقعی در سطح route است، نه تغییر کامپوننتهای فعلی).
|
||||||
|
|
||||||
|
### ۷. اضافه کردن `error.js` در سطح root و برای مسیرهای پرتقاضا
|
||||||
|
|
||||||
|
یک `app/error.js` (Client Component با `"use client"`) برای گرفتن خطاهای رندر، و یک `error.js` در `app/doctor/[slug]/` برای حالتی که fetch واقعاً fail میکند (بهجای برگرداندن `null` خاموش):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
"use client";
|
||||||
|
export default function Error({ error, reset }) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center" dir="rtl">
|
||||||
|
<p>مشکلی پیش آمد. لطفاً دوباره تلاش کنید.</p>
|
||||||
|
<button onClick={() => reset()}>تلاش دوباره</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۸. بررسی `app/sitemap.js` و `app/robots.js`
|
||||||
|
|
||||||
|
تأیید کن:
|
||||||
|
- `sitemap.js` از همان axios/fetchReq استفاده نمیکند بدون cache (احتمال timeout زیر بار)؛ در صورت لزوم به fetch بومی با `revalidate` بزرگ (مثلاً ۲۴ ساعت) تغییر بده.
|
||||||
|
- صفحات `panel/*` و `dashboard` در sitemap نباشند (نیاز auth دارند).
|
||||||
|
- `robots.js` در حالت `DEV_MODE=TRUE` همهچیز را drop میکند (طبق `CLAUDE.md` همین الان این رفتار مستند است) — فقط تأیید کن پیادهسازی با مستندات همخوان است.
|
||||||
|
|
||||||
|
### ۹. گزارش نهایی بهصورت جدول
|
||||||
|
|
||||||
|
برای هر صفحهی زیر جدول را تکمیل کن — این لیست کامل صفحات `app/` پروژه است (تمام موارد را پوشش بده، هیچکدام را رد نکن):
|
||||||
|
|
||||||
|
`/`, `/about-us`, `/contact-us`, `/specialties`, `/blogs`, `/blog/[slug]`, `/clinics`, `/clinic/[slug]`, `/doctors`, `/doctor/[slug]`, `/appointment/[doctorId]`, `/login`, `/login-verify`, `/dashboard`, `/panel/add-doctor`, `/panel/dashboard` (route group), `/panel/turns`, `/panel/user-account`, `/payment/[uuid]`, `/payment/result`
|
||||||
|
|
||||||
|
| Page | Current Strategy | Recommended Strategy | Reason | SEO Impact | Performance Impact |
|
||||||
|
|------|------------------|----------------------|--------|-------------|---------------------|
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **هیچ تغییری در `services/api.js`/`services/response.js` (مسیر axios سمت کلاینت) ندهی** — فقط فراخوانیهای Server Component (`app/.../page.js`) که با `axiosInstance`/`fetchReq` کار میکنند هدف این پرامپت هستند.
|
||||||
|
- صفحات `panel/*` و `dashboard` چون نیاز به session/JWT کاربر دارند و داده per-user است، **باید SSR/dynamic بمانند** — این صفحات را به ISR/SSG تبدیل نکن؛ فقط در گزارش جدول توضیح بده چرا.
|
||||||
|
- `getStateInfo()` به `host` header وابسته است (تشخیص subdomain چندشهری) — این یعنی صفحاتی که از آن استفاده میکنند (`generateMetadata` همه صفحات public) را نمیتوان بهطور کامل static کرد مگر با `generateStaticParams` محدود به دامنههای شناختهشده در `data/city.json`؛ اگر چنین تغییری پیشنهاد میشود، توضیح بده trade-off چندشهری بودن چیست.
|
||||||
|
- پس از هر تغییر در `app/.../page.js`، طبق قانون پروژه (`CLAUDE.md`): «همیشه `await params`» را رعایت کن — این الگو همین الان در همه فایلها هست، نشکن.
|
||||||
|
- بعد از تغییرات، حتماً `npm run build` را اجرا کن و خروجی Route را بررسی کن — ستون `Size`/`First Load JS` باید تغییر معنادار (کاهش یا حداقل عدم افزایش) داشته باشد.
|
||||||
|
- `npm run lint` در این پروژه به دلیل عدم migrate شدن از `next lint` به ESLint CLI، interactive میپرسد و کار نمیکند (مشکل از قبل موجود، نه نتیجه این تغییرات) — برای validation فقط به `npm run build` تکیه کن.
|
||||||
|
- تمام متنهای جدید (پیام خطا، loading text و غیره) باید فارسی و RTL باشند، مطابق بقیهی پروژه.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<div dir="rtl" markdown="1">
|
||||||
|
|
||||||
|
# تأیید زندهٔ فیکسهای SEO بعد از deploy
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
فیکسهای ممیزی SEO (P1–P11 از `seo-critical-fixes-live-audit.md`) پیاده و تست شدهاند، اما تأیید روی **build محلی production** انجام شد، نه روی دامنههای واقعی:
|
||||||
|
|
||||||
|
```
|
||||||
|
NEXT_PUBLIC_API_URL=https://clinic-pro.ir DEV_MODE=FALSE npm run build
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0 npx next start -p 3111
|
||||||
|
# سپس curl با هدر Host برای شبیهسازی دامنههای شهری
|
||||||
|
```
|
||||||
|
|
||||||
|
معیارهای پذیرش اصلی «curl روی production» بودند. این پرامپت همانها را روی دامنههای واقعی اجرا میکند.
|
||||||
|
|
||||||
|
**دو نکتهٔ حیاتی قبل از تفسیر هر خروجی:**
|
||||||
|
|
||||||
|
۱. **`DEV_MODE` باید `FALSE` باشد.** با `DEV_MODE=TRUE` کل سایت `noindex, nofollow, nocache` میگیرد و همهٔ تستهای robots بیمعنی میشوند. این مقدار در `next.config.js` از طریق `env` در **زمان build** درون کد جاسازی میشود — تغییر آن در runtime بیاثر است و نیاز به build مجدد دارد.
|
||||||
|
|
||||||
|
۲. **تغییرات هنوز commit نشدهاند** (وضعیت کاری). قبل از deploy باید commit و push شوند.
|
||||||
|
|
||||||
|
## هدف
|
||||||
|
|
||||||
|
اجرای کامل معیارهای پذیرش روی production و ثبت خروجی واقعی؛ و در صورت اختلاف با نتایج محلی، ریشهیابی.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. پیش از deploy
|
||||||
|
|
||||||
|
- `git status` را بررسی کن؛ تغییرات SEO را commit کن.
|
||||||
|
- تأیید کن build با `DEV_MODE=FALSE` انجام میشود.
|
||||||
|
- تأیید کن `NEXT_PUBLIC_API_URL` در محیط production درست است (نه `clinic-pro.ddev.site` که مقدار فعلی `.env` محلی است).
|
||||||
|
|
||||||
|
### ۲. اجرای معیارهای پذیرش روی production
|
||||||
|
|
||||||
|
بعد از deploy، اینها را اجرا کن و **خروجی واقعی هر کدام را در گزارش نقل کن**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# P3 — noindex سیستمیک لیستها (باید خالی باشد)
|
||||||
|
for h in yasuj-nobat.ir tehran-nobat.ir yazd-nobat.ir nobat724.com; do
|
||||||
|
for p in doctors clinics; do
|
||||||
|
printf "%s/%s: " "$h" "$p"
|
||||||
|
curl -s "https://$h/$p" | grep -o '<meta name="robots"[^>]*>' | head -1; echo
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# P3 — فیلتر واقعی باید noindex بماند
|
||||||
|
curl -s "https://yasuj-nobat.ir/doctors?name=x" | grep -o 'content="noindex[^"]*"'
|
||||||
|
curl -s "https://yasuj-nobat.ir/doctors?specialty=پوست%20و%20مو&gender=woman" | grep -o 'content="noindex[^"]*"'
|
||||||
|
|
||||||
|
# P4 — C1-a self-canonical
|
||||||
|
for h in yazd-nobat.ir nobat724.com yasuj-nobat.ir; do
|
||||||
|
for p in "" doctors clinics specialties blogs; do
|
||||||
|
printf "%s/%s -> " "$h" "$p"
|
||||||
|
curl -s "https://$h/$p" | grep -o 'rel="canonical" href="[^"]*"'; echo
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# P4 — C1-c استاتیک مشترک → دامنهٔ اصلی
|
||||||
|
curl -s https://yasuj-nobat.ir/about-us | grep -o 'rel="canonical" href="[^"]*"'
|
||||||
|
curl -s https://yasuj-nobat.ir/contact-us | grep -o 'rel="canonical" href="[^"]*"'
|
||||||
|
|
||||||
|
# P4 — C1-b موجودیت → دامنهٔ شهر خودش
|
||||||
|
D=beaca548-816f-4613-937d-360db01bd7c8 # شهر: یاسوج
|
||||||
|
C=9c163d69-0051-4745-a423-c830135b1c01 # شهر: یاسوج
|
||||||
|
curl -s "https://nobat724.com/doctor/$D" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir
|
||||||
|
curl -s "https://yazd-nobat.ir/doctor/$D" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir
|
||||||
|
curl -s "https://nobat724.com/clinic/$C" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir
|
||||||
|
|
||||||
|
# P4 — هماهنگی JSON-LD با canonical (تناقض زندهٔ قبلی)
|
||||||
|
curl -s "https://nobat724.com/clinic/$C" | grep -o '"url":"[^"]*"'
|
||||||
|
|
||||||
|
# M3-B — pagination
|
||||||
|
curl -s "https://yasuj-nobat.ir/doctors?page=2" | grep -o 'rel="canonical" href="[^"]*"' # → ?page=2
|
||||||
|
curl -s "https://yasuj-nobat.ir/doctors?page=1" | grep -o 'rel="canonical" href="[^"]*"' # → بدون page
|
||||||
|
|
||||||
|
# P6 — Soft-404 (هر چهار باید 404 بدهند)
|
||||||
|
for p in doctor/invalid-xxx clinic/invalid-xxx blog/invalid-xxx specialties/invalid-slug; do
|
||||||
|
printf "%s -> " "$p"; curl -o /dev/null -s -w "%{http_code}\n" "https://nobat724.com/$p"
|
||||||
|
done
|
||||||
|
# و موجودیت معتبر باید 200 بماند
|
||||||
|
curl -o /dev/null -s -w "valid doctor: %{http_code}\n" "https://nobat724.com/doctor/$D"
|
||||||
|
|
||||||
|
# P7 — دقیقاً یک h1 و شهرمحور
|
||||||
|
for p in doctors clinics specialties; do
|
||||||
|
printf "%s h1count=" "$p"; curl -s "https://yasuj-nobat.ir/$p" | grep -c "<h1"
|
||||||
|
curl -s "https://yasuj-nobat.ir/$p" | grep -o "<h1[^>]*>[^<]*"
|
||||||
|
done
|
||||||
|
curl -s "https://yasuj-nobat.ir/clinic/$C" | grep -c "<h1" # → 1 (قبلاً 4)
|
||||||
|
curl -s "https://nobat724.com/doctors" | grep -o "<title>[^<]*" # → بدون «نوبت 724» بهجای شهر
|
||||||
|
|
||||||
|
# P8 — breadcrumb
|
||||||
|
curl -s https://yasuj-nobat.ir/clinics | grep -c 'aria-label="breadcrumb"'
|
||||||
|
curl -s https://yasuj-nobat.ir/clinics | grep -c 'BreadcrumbList'
|
||||||
|
curl -s "https://yasuj-nobat.ir/clinic/$C" | grep -c 'aria-label="breadcrumb"'
|
||||||
|
|
||||||
|
# P9 — صفحات فرود تخصص
|
||||||
|
curl -o /dev/null -s -w "%{http_code}\n" https://tehran-nobat.ir/specialties/dermatology # → 200
|
||||||
|
curl -o /dev/null -s -w "%{http_code}\n" https://tehran-nobat.ir/specialties/invalid-slug # → 404
|
||||||
|
curl -s "https://tehran-nobat.ir/doctors?specialty=پوست%20و%20مو" | grep -o 'rel="canonical" href="[^"]*"'
|
||||||
|
curl -s https://yasuj-nobat.ir/specialties | grep -c "doctors?specialty=" # → 0
|
||||||
|
|
||||||
|
# P5 — sitemap
|
||||||
|
curl -s https://nobat724.com/sitemap.xml | grep -c "<loc>"
|
||||||
|
curl -s https://yasuj-nobat.ir/sitemap.xml | grep -c "<loc>"
|
||||||
|
curl -s https://yasuj-nobat.ir/sitemap.xml | grep -c "$D" # → 1
|
||||||
|
curl -s https://nobat724.com/sitemap.xml | grep -c "$D" # → 0
|
||||||
|
curl -s https://yasuj-nobat.ir/sitemap.xml | grep -c "9c163d69" # → 0 (کلینیک بیمحتوا)
|
||||||
|
|
||||||
|
# P11 — FAQPage بدون شکستن اسکیماهای موجود
|
||||||
|
curl -s https://tehran-nobat.ir/specialties/dermatology | grep -c FAQPage
|
||||||
|
curl -s "https://yasuj-nobat.ir/doctor/$D" | grep -c FAQPage
|
||||||
|
curl -s "https://yasuj-nobat.ir/doctor/$D" | grep -c Physician # باید سالم بماند
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳. اعتبارسنجی ساختاریافته
|
||||||
|
|
||||||
|
- **Rich Results Test گوگل** روی یک صفحهٔ پزشک و یک صفحهٔ تخصص: تأیید کن `Physician` / `MedicalClinic` / `Review` / `BreadcrumbList` قبلی سالماند و `FAQPage` معتبر است.
|
||||||
|
- تأیید کن همهٔ بلوکهای JSON-LD پارس میشوند (در تست محلی: ۰ خطای پارس، انواع `Organization, WebSite, Physician, FAQPage, BreadcrumbList`).
|
||||||
|
|
||||||
|
### ۴. بازبینی بصری
|
||||||
|
|
||||||
|
صفحات تغییریافته را با نسخهٔ قبل مقایسه کن. تغییر تگها باید بدون اثر بصری باشد:
|
||||||
|
|
||||||
|
| صفحه | تغییر | انتظار |
|
||||||
|
|------|-------|--------|
|
||||||
|
| `/` | خط برند `h1` → `p`؛ شعار `h2` → `h1` | ظاهر یکسان |
|
||||||
|
| `/doctors` | متن دکمهٔ موقعیت `h1` → `span` + h1 جدید و پاراگراف مقدمه | دکمه بدون تغییر؛ عنوان و متن جدید بالای لیست |
|
||||||
|
| `/clinics` | عنوان پویا + پاراگراف مقدمه + breadcrumb با «خانه» | «خانه >» به breadcrumb اضافه شده |
|
||||||
|
| `/clinic/[id]` | ۳ عنوان بخش `h1` → `h2`؛ breadcrumb بصری جدید | تایپوگرافی یکسان |
|
||||||
|
|
||||||
|
### ۵. Search Console
|
||||||
|
|
||||||
|
برای هر دامنهٔ درگیر: URL Inspection روی صفحات کلیدی (`/`، `/doctors`، `/clinics`، یک صفحهٔ پزشک، یک صفحهٔ تخصص) و ثبت sitemap. re-index چند هفته طول میکشد.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **رگرسیون احتمالی که باید مشخصاً چک شود:** حذف `loading.js` از سه مسیر `doctor/[slug]`، `clinic/[slug]`، `blog/[slug]`. این کار برای رفع Soft-404 لازم بود (Suspense باعث میشد پاسخ با ۲۰۰ استریم شود و `notFound()` دیگر نتواند status را عوض کند). هزینهاش نبودِ اسکلت لودینگ هنگام ناوبری کلاینتی است — تجربهٔ کاربری این سه مسیر را عملاً بررسی کن.
|
||||||
|
- اگر `robots` روی همهٔ صفحات `noindex, nofollow, nocache` بود، تقریباً قطعاً `DEV_MODE=TRUE` در build بوده — کد را دنبال نکن، اول build را بررسی کن.
|
||||||
|
- ArvanCloud ممکن است روی دامنهٔ اصلی bot-challenge داشته باشد؛ اگر curl خروجی غیرمنتظره داد، قبل از دیباگ کد این را رد کن. (خارج از دامنهٔ اپ — DevOps)
|
||||||
|
- sitemap دامنهٔ اصلی ~۱۳ ثانیه طول میکشد (۳۵ sweep برای تفکیک شهر). اگر timeout خورد، این انتظار است نه باگ — رفعش در `sitemap-simplify-with-city.md`.
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# نوبتدهی آنلاین بر اساس سرویس (Service-first booking)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی نوبتدهی).
|
||||||
|
**Cross-repo:** وابسته به پرامپت backend `clinicpro/.claude/prompt/service-based-booking.md` — آن **اول** اجرا شود؛ این پرامپت endpoint و متای آن را مصرف میکند.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
جریان فعلیِ نوبتگیری آنلاین یک wizard چندمرحلهای است بدون انتخاب سرویس:
|
||||||
|
`/appointment/[doctorId]` → روز → ساعت (اسلات ثابت) → لاگین/OTP → فرم بیمار → پرداخت.
|
||||||
|
اسلاتها از `GET /api/v1/appointment-slots?doctor_uuid&date` میآیند و همه هماندازهاند.
|
||||||
|
|
||||||
|
بکاند حالت جدید «نوبتدهی بر اساس سرویس» را اضافه میکند: مدت نوبت = مجموع مدت سرویسهای انتخابی، و endpoint جدید زمانهای خالیِ کافی را برمیگرداند. سایت باید در این حالت **اول سرویس** را از بیمار بگیرد، سپس فقط زمانهای خالیِ کافی را نشان دهد.
|
||||||
|
|
||||||
|
## هدف / spec انگلیسی
|
||||||
|
|
||||||
|
When a doctor uses `booking_mode = service`, insert a service-selection step **before** the date/time step. The patient picks one or more services; the site fetches candidate start times sized to the summed service duration from the new backend endpoint, shows only those, locks the chosen time via `postAppointment` (with the service items), then continues to the existing login/pay flow. When the doctor is in `slot` mode, the current flow is unchanged. «نوبت آزاد» (reserve) is secretary-only and never shown online.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `services/response.js` (L52 `getAppointmentSlots`, L57 `getMonthAvailability`, L62 `postAppointment`, L76 `getDoctorServices`) | افزودن `getServiceSlots` + رساندن `service_item_uuids` به postAppointment |
|
||||||
|
| `components/appointment/index.js` (L45 `AppointmentPage`, state L59-62) | افزودن state سرویسهای انتخابی + مدت |
|
||||||
|
| `components/appointment/Container.js` (L42 `elements[]` step router) | افزودن مرحلهٔ انتخاب سرویس در ابتدای wizard (حالت سرویس) |
|
||||||
|
| `app/component/date/dateTime/index.js` (L29-52 fetch اسلات) | در حالت سرویس، فراخوانی endpoint سرویس با مدت مجموع |
|
||||||
|
| `lib/appointmentSlots.js` (L1 `adaptSlots`, L17 `hasAvailable`) | adapter برای پاسخ `start_times` |
|
||||||
|
| `components/appointment/detail/SubmitData.js` (L138-165 payload) | افزودن `service_item_uuids` به appointmentPayload |
|
||||||
|
| `app/appointment/[doctorId]/page.js` (L9, doctor fetch L17) | رساندن `booking_mode` و لیست سرویسها به AppointmentPage |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
### fetch اسلات ثابت
|
||||||
|
```js
|
||||||
|
// app/component/date/dateTime/index.js:29
|
||||||
|
request.getAppointmentSlots(doctor.uuid, dateStr).then((res) => {
|
||||||
|
const parsed = adaptSlots(res); // data.sessions[].slots
|
||||||
|
...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
```js
|
||||||
|
// services/response.js:52
|
||||||
|
getAppointmentSlots: (doctor_uuid, date) =>
|
||||||
|
api.get(`api/v1/appointment-slots?doctor_uuid=${doctor_uuid}&date=${date}`),
|
||||||
|
```
|
||||||
|
|
||||||
|
### payload رزرو — بدون سرویس
|
||||||
|
```js
|
||||||
|
// components/appointment/detail/SubmitData.js:138
|
||||||
|
const appointmentPayload = {
|
||||||
|
doctor_uuid, slot_start: selectedSlot.start, slot_end: selectedSlot.end,
|
||||||
|
for_self, patient_national_code, patient_gender, city_id,
|
||||||
|
}; // هیچ service_id ندارد
|
||||||
|
request.postAppointment(appointmentPayload); // POST api/v1/appointment
|
||||||
|
```
|
||||||
|
|
||||||
|
### step router
|
||||||
|
```js
|
||||||
|
// components/appointment/Container.js:42
|
||||||
|
const elements = [Date, Login, Verify, Detail, Paying, SuccessPay, FailedPay];
|
||||||
|
// step: 0 روز/ساعت, 1 لاگین, 2 OTP, 3 فرم, 4 پرداخت ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. سرویس در response.js
|
||||||
|
|
||||||
|
```js
|
||||||
|
// services/response.js
|
||||||
|
getServiceSlots: (doctor_uuid, date, serviceItemUuids) => {
|
||||||
|
const q = serviceItemUuids.map(u => `service_item_uuids[]=${encodeURIComponent(u)}`).join('&');
|
||||||
|
return api.get(`api/v1/appointment-service-slots?doctor_uuid=${doctor_uuid}&date=${date}&${q}`);
|
||||||
|
},
|
||||||
|
```
|
||||||
|
و اجازهٔ `service_item_uuids` در `postAppointment` (فقط pass-through payload).
|
||||||
|
منبع لیست سرویسهای پزشک: `getDoctorServices` (L76, `api/v1/categorys/doctor_services`) یا endpoint سرویسهای واقعیِ کلینیک اگر پزشک ServiceItem دارد — بررسی کن کدام سرویسها `duration_minutes` دارند (فقط همانها قابلانتخاب برای این جریاناند).
|
||||||
|
|
||||||
|
### ۲. تشخیص حالت پزشک
|
||||||
|
|
||||||
|
از پاسخ doctor یا از `getMonthAvailability` مقدار `booking_mode` را بخوان (backend آن را در متای weekly-schedule دارد؛ اگر در پاسخ doctor نبود، از یک فیلد مناسب که backend اضافه میکند). در `AppointmentPage`:
|
||||||
|
- `booking_mode === 'service'` → مرحلهٔ انتخاب سرویس فعال شود.
|
||||||
|
- در غیر این صورت → **دقیقاً** جریان فعلی (هیچ تغییری).
|
||||||
|
|
||||||
|
### ۳. مرحلهٔ انتخاب سرویس (فقط حالت سرویس)
|
||||||
|
|
||||||
|
- کامپوننت جدید `components/appointment/service/index.js`: لیست سرویسهای پزشک با مدت و قیمت؛ انتخاب یک/چند سرویس؛ نمایش «مدت کل» = Σ `duration_minutes`.
|
||||||
|
- در `Container.js` این مرحله را **قبل** از انتخاب روز قرار بده (حالت سرویس) و state سرویسها را در `AppointmentPage` نگهدار.
|
||||||
|
|
||||||
|
### ۴. نمایش فقط زمانهای کافی
|
||||||
|
|
||||||
|
- در `app/component/date/dateTime/index.js`: اگر حالت سرویس است، بهجای `getAppointmentSlots` از `getServiceSlots(uuid, dateStr, selectedServiceUuids)` استفاده کن.
|
||||||
|
- `lib/appointmentSlots.js`: یک adapter برای پاسخ `data.start_times` (آرایهٔ `{start,end,start_time,location_id}`) اضافه کن که همان ساختار مورد انتظارِ لیست ساعت را بسازد (هر مورد یک دکمهٔ زمان). چون همه از قبل «کافی» هستند، `is_available=true`.
|
||||||
|
- اگر `start_times` خالی بود: پیام «برای این سرویس در این روز زمان خالی کافی نیست» + هدایت به روز بعدِ دارای ظرفیت (از `getMonthAvailability` برای فعال/غیرفعال بودن روزها استفاده کن).
|
||||||
|
|
||||||
|
### ۵. قفل زمان هنگام ثبت
|
||||||
|
|
||||||
|
- در `SubmitData.js` به `appointmentPayload` کلید `service_item_uuids: [...]` اضافه کن (حالت سرویس). `slot_start` از انتخاب میآید؛ `slot_end` را backend از مدت سرویس محاسبه میکند (به مقدار کلاینت اعتماد نمیشود) ولی همان `selectedSlot.end` را هم بفرست تا سازگاری حفظ شود.
|
||||||
|
- منطق قفل دو-مرحلهای موجود (`postAppointment` → `expires_at` → شمارش معکوس در `paying/index.js` → redirect به `payment/order`) دستنخورده بماند؛ فقط payload سرویس اضافه میشود. مدیریت خطای 409 (reset به step 0، `SubmitData.js:178`) همان بماند.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **«نوبت آزاد» آنلاین نیست.** هیچ مسیری برای `is_reserve` در سایت اضافه نکن؛ فقط منشی در پنل. (در سایت «رزرو» فقط برچسب بازاریابی/عنوان است، نه مدل داده — `Container.js:102`, `payment/[uuid]/page.js:206`.)
|
||||||
|
- **حالت اسلاتی دستنخورده:** وقتی `booking_mode !== 'service'`، هیچ کامپوننت/فراخوانیِ فعلی نباید تغییر رفتار بدهد. مرحلهٔ سرویس فقط شرطی render شود.
|
||||||
|
- **فقط سرویسهای دارای مدت:** سرویسی که `duration_minutes` ندارد نباید در این جریان قابلانتخاب باشد (backend هم ۴۲۲ میدهد) — در UI غیرفعال/مخفی کن.
|
||||||
|
- **الگوهای پروژه:** App Router، `await params`؛ فراخوانی API از `services/response.js` (interceptor پاسخ را در `services/api.js:73` به `response.data` تبدیل میکند)؛ تاریخ Jalali با `moment`/`jalali-moment`؛ RTL، فونت Vazir، MUI v5 + Tailwind؛ رشتههای UI فارسی. slug پزشک = `uuid`.
|
||||||
|
- **تست:** به `nobat724-test-suite` پروژه اضافه کن — رندر مرحلهٔ سرویس در حالت سرویس، عدمرندر در حالت اسلاتی، محاسبهٔ مدت کل، adapter `start_times`، افزودن `service_item_uuids` به payload.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<div dir="rtl" markdown="1">
|
||||||
|
|
||||||
|
# سادهسازی sitemap با شهرِ موجود در پاسخ + آستانهٔ sitemap-index
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
**پیشنیاز قطعی:** `clinicpro/.claude/prompt/doctors-list-city-and-limit.md` اجرا و deploy شده باشد.
|
||||||
|
تا وقتی `GET /api/v1/doctors` فیلد `city` برنگرداند، وظیفهٔ ۱ قابل انجام نیست (وظیفهٔ ۲ مستقل است).
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
sitemap هر دامنه فقط باید موجودیتهایی را داشته باشد که canonical آنها همان دامنه است. برای کلینیکها ساده است چون پاسخ لیست `city` دارد. برای پزشکان **پاسخ لیست شهر ندارد**، پس sitemap دامنهٔ اصلی مجبور است این کار پرهزینه را بکند: کل لیست را بگیرد، بعد **۳۵ بار** لیست را با `city_id` هر شهرِ دامنهدار بگیرد، و تفاضل بگیرد.
|
||||||
|
|
||||||
|
نتیجه: تولید sitemap دامنهٔ اصلی ~۱۳ ثانیه (revalidate ساعتی، پس هزینه مستهلک میشود، ولی کد پیچیده و شکننده است).
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
`app/sitemap.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// شهرهایی که دامنهٔ اختصاصی دارند — موجودیتهایشان canonical روی همان دامنه دارند و
|
||||||
|
// نباید در sitemap دامنهٔ اصلی تکرار شوند.
|
||||||
|
const CITY_IDS_WITH_DOMAIN = citiesData
|
||||||
|
.filter((c) => !isRootCity(c) && c.domain)
|
||||||
|
.map((c) => c.id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پاسخ لیست پزشکان شهر ندارد؛ پس تفکیک با فیلتر سمت API انجام میشود:
|
||||||
|
* روی دامنهٔ شهری با city_id، و روی دامنهٔ اصلی «همه منهای پزشکانِ شهرهای دامنهدار».
|
||||||
|
*/
|
||||||
|
async function getDoctorsForScope(scope) {
|
||||||
|
if (!scope.isRoot) return fetchAllPages('/api/v1/doctors', cityFilterParams(scope));
|
||||||
|
|
||||||
|
const [all, ...perCity] = await Promise.all([
|
||||||
|
fetchAllPages('/api/v1/doctors'),
|
||||||
|
...CITY_IDS_WITH_DOMAIN.map((cityId) =>
|
||||||
|
fetchAllPages('/api/v1/doctors', { city_id: String(cityId) })
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const ownedByCityDomain = new Set(perCity.flat().map((d) => d?.uuid).filter(Boolean));
|
||||||
|
return all.filter((d) => !ownedByCityDomain.has(d?.uuid));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
کلینیکها از قبل الگوی ساده و درست را دارند (چون `city` در پاسخ هست):
|
||||||
|
|
||||||
|
```js
|
||||||
|
.filter((c) => !scope.isRoot || !findDomainByCityId(extractEntityCityId(c)))
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. حذف ۳۵ sweep و یکسانسازی با الگوی کلینیک
|
||||||
|
|
||||||
|
وقتی `city` در پاسخ لیست پزشکان آمد:
|
||||||
|
|
||||||
|
- `getDoctorsForScope` و `CITY_IDS_WITH_DOMAIN` حذف شوند.
|
||||||
|
- روی دامنهٔ شهری: همان `fetchAllPages('/api/v1/doctors', cityFilterParams(scope))` بماند.
|
||||||
|
- روی دامنهٔ ریشه: یک fetch کامل + همان فیلتر کلینیکها:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function getDoctorUrls(baseUrl, scope) {
|
||||||
|
const doctors = await fetchAllPages('/api/v1/doctors', cityFilterParams(scope));
|
||||||
|
return doctors
|
||||||
|
.filter((d) => !isThinDoctor(d))
|
||||||
|
.filter((d) => !scope.isRoot || !findDomainByCityId(extractEntityCityId(d)))
|
||||||
|
.map((d) => withLastModified({ /* ... */ }, d.updated || d.created));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`extractEntityCityId` بدون تغییر کار میکند (شکل `city: {id}` را میپذیرد).
|
||||||
|
|
||||||
|
**تأیید کن پوشش تغییر نکرده باشد** — قبل و بعد از تغییر تعداد URL هر sitemap را مقایسه کن:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://yasuj-nobat.ir/sitemap.xml | grep -c "<loc>"
|
||||||
|
curl -s https://nobat724.com/sitemap.xml | grep -c "<loc>"
|
||||||
|
```
|
||||||
|
|
||||||
|
مقدار مرجع در زمان نگارش: یاسوج **۵۵۴** URL (۴۵۴ پزشک)، ریشه **۱۰۰** URL (۰ پزشک — همهٔ پزشکان شهر دامنهدار دارند).
|
||||||
|
|
||||||
|
### ۲. آستانهٔ sitemap-index (مستقل از backend)
|
||||||
|
|
||||||
|
الان همهٔ URLها در یک `sitemap.xml` هستند. سقف استاندارد **۵۰٬۰۰۰ URL** در هر فایل است.
|
||||||
|
|
||||||
|
مقیاس فعلی: ۲۳۴۱ پزشک، ۲ کلینیک، ۹۳ صفحهٔ تخصص، ۰ بلاگ ⇒ حداکثر ~۲٬۵۰۰ URL در هر دامنه. **بسیار زیر سقف، پس sitemap-index الان لازم نیست** و عمداً پیاده نشده.
|
||||||
|
|
||||||
|
کاری که این وظیفه میخواهد:
|
||||||
|
|
||||||
|
- یک هشدار صریح اضافه کن که وقتی تعداد URL از یک آستانه (مثلاً ۴۵٬۰۰۰) گذشت، در لاگ دیده شود:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (allUrls.length > 45000) {
|
||||||
|
console.warn(`[sitemap] ${domain}: ${allUrls.length} URLs — نزدیک سقف ۵۰k، sitemap-index لازم است`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- تصمیم «فعلاً sitemap-index نداریم و چرا» را بهصورت کامنت کنار همین شرط مستند کن، تا دفعهٔ بعد کسی آن را بهعنوان قلمافتادگی برندارد.
|
||||||
|
- sitemap-index را **پیاده نکن** مگر عدد واقعی به آستانه نزدیک شده باشد؛ با `generateSitemaps` ساختار URL عوض میشود (`/sitemap/[id].xml`) و ارجاع `robots.txt` باید همراهش تغییر کند.
|
||||||
|
|
||||||
|
### ۳. بازبینی سقف `limit` بعد از تغییر backend
|
||||||
|
|
||||||
|
حلقهٔ صفحهبندی الان روی `meta.totalPages` توقف میکند (نه روی `items.length < limit`) — این عمدی و درست است:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const PAGE_LIMIT = 50;
|
||||||
|
const MAX_PAGES = 200;
|
||||||
|
// ...
|
||||||
|
if (totalPages && page >= totalPages) break;
|
||||||
|
if (totalRecords && results.length >= totalRecords) break;
|
||||||
|
```
|
||||||
|
|
||||||
|
اگر backend سقف `limit` را بالا برد، `PAGE_LIMIT` را متناسب بالا ببر و `MAX_PAGES` را بازبینی کن. **شرط توقف را به `items.length < PAGE_LIMIT` برنگردان** — دقیقاً همین باگ باعث شده بود sitemap روی ۵۰ رکورد بریده بماند.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **این تغییر نباید هیچ URLی را از sitemap حذف یا اضافه کند** — فقط راه رسیدن به همان نتیجه سادهتر میشود. اختلاف در تعداد URL یعنی رگرسیون؛ ریشهیابی کن.
|
||||||
|
- `isThinDoctor` در `lib/entityQuality.js` روی شکل پاسخِ **لیست** کار میکند (`specialties` دارد، `address` ندارد). اگر backend شکل پاسخ لیست را عوض کرد، معیار `hasLocation` را بازبینی کن — با آمدن `city` در پاسخ، حالا `hasLocation` میتواند از `city` هم سیگنال بگیرد و پزشکان بیشتری وارد sitemap شوند.
|
||||||
|
- منطق «چه چیزی noindex است» و «چه چیزی در sitemap است» باید یکی بماند؛ هر دو از `lib/entityQuality.js` میآیند. اگر یکی را عوض کردی، دیگری خودکار همراه میشود — عمداً همینطور طراحی شده.
|
||||||
|
- بعد از تغییر: `graphify update .`
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# نمایش تعداد پزشکان هر تخصص در صفحه /specialties
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی).
|
||||||
|
|
||||||
|
> **Cross-repo:** وابسته به endpoint بکاند `GET /api/v1/specialties/doctor-counts?city_id=<id>` (پرامپت `clinicpro/.claude/prompt/specialty-doctor-counts.md`). آن **اول** اجرا شود.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
صفحهی `/specialties` کارت هر تخصص را از `data/specialties.json` (ثابت) رندر میکند و زیرش `{data.number_of_doctors} پزشک` مینویسد — اما `specialties.json` فیلد `number_of_doctors` ندارد، پس همیشه خالی است. باید تعداد واقعی پزشکانِ هر تخصص **در شهرِ دامنهی جاری** از API گرفته و نمایش داده شود.
|
||||||
|
|
||||||
|
شهر از subdomain تشخیص داده میشود: server-side با `getStateInfo()` → `matchedCity.id` (همان city id بکاند).
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
صفحه بهجای json ثابت، لیست تخصصها را با `number_of_doctors` از endpoint جدید (با `city_id` شهر جاری) بگیرد و نمایش دهد.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `app/specialties/page.js` | Server Component — `matchedCity.id` را از `getStateInfo` بگیر و به صفحه بده |
|
||||||
|
| `components/specialties/index.js` | `"use client"` — fetch count از API بهجای json ثابت |
|
||||||
|
| `components/specialties/list/ItemSpecialties.js` | نمایش `data.number_of_doctors` (موجود — تغییر لازم ندارد) |
|
||||||
|
| `services/response.js` | افزودن wrapper `getSpecialtyDoctorCounts(cityId)` |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
`ItemSpecialties.js` از قبل تعداد را نشان میدهد (فقط داده ندارد):
|
||||||
|
```jsx
|
||||||
|
<p className="text-[#7E7E7E] text-[14px] font-medium">
|
||||||
|
{data.number_of_doctors} پزشک
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
`components/specialties/index.js`:
|
||||||
|
```jsx
|
||||||
|
import specialtiesData from "@/data/specialties.json";
|
||||||
|
const [filteredSpecialties, setFilteredSpecialties] = useState(specialtiesData);
|
||||||
|
const handleSearch = (value) => setFilteredSpecialties(searchOnList(specialtiesData, value, "name"));
|
||||||
|
```
|
||||||
|
|
||||||
|
`app/specialties/page.js`:
|
||||||
|
```jsx
|
||||||
|
function Specialties() {
|
||||||
|
return (<Layout name="/specialties"><SpecialtiesPage /></Layout>);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
پاسخ بکاند `GET /api/v1/specialties/doctor-counts?city_id=<id>`: `{ success, data: { data: [ {id, name, slug, parent_id, number_of_doctors, ...} ] } }` (double-nested — `success(['data'=>...])`).
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. wrapper در `services/response.js`
|
||||||
|
```js
|
||||||
|
getSpecialtyDoctorCounts: (cityId) =>
|
||||||
|
api.get(`api/v1/specialties/doctor-counts`, { params: { city_id: cityId } }),
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. پاسدادن city id از صفحهی server به کامپوننت client
|
||||||
|
در `app/specialties/page.js`:
|
||||||
|
```jsx
|
||||||
|
import { getStateInfo } from "@/lib/getStateInfo";
|
||||||
|
async function Specialties() {
|
||||||
|
const { matchedCity } = await getStateInfo();
|
||||||
|
return (
|
||||||
|
<Layout name="/specialties">
|
||||||
|
<SpecialtiesPage cityId={matchedCity?.id ?? null} />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(صفحه باید `async` شود؛ `generateMetadata` موجود دستنخورده.)
|
||||||
|
|
||||||
|
### ۳. fetch در `components/specialties/index.js`
|
||||||
|
- prop `cityId` بگیر.
|
||||||
|
- در `useEffect` (mount / تغییر cityId) `request.getSpecialtyDoctorCounts(cityId)` را صدا بزن، `res.data.data` را در state بگذار.
|
||||||
|
- تا رسیدن داده، میتوان از `specialties.json` بهعنوان نمایش اولیه استفاده کرد (یا اسکلت)، ولی منبعِ نهایی API است.
|
||||||
|
- جستجو روی همان لیستِ API اعمال شود (`searchOnList(list, value, "name")`).
|
||||||
|
- فقط تخصصهای ریشه (یا همان رفتار فعلی) نمایش داده شوند؛ اگر API همه را میدهد و قبلاً json هم همه را داشت، رفتار را حفظ کن.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const [all, setAll] = useState([]);
|
||||||
|
const [filtered, setFiltered] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
request.getSpecialtyDoctorCounts(cityId)
|
||||||
|
.then((res) => { const items = res?.data?.data ?? []; setAll(items); setFiltered(items); })
|
||||||
|
.catch(() => { setAll([]); setFiltered([]); });
|
||||||
|
}, [cityId]);
|
||||||
|
const handleSearch = (value) => setFiltered(searchOnList(all, value, "name"));
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۴. `ItemSpecialties.js`
|
||||||
|
بدون تغییر ساختار؛ فقط مطمئن شو `data.number_of_doctors` (که حالا از API میآید) و `data.id` (برای لینک `/doctors?specialties=`) درستاند. اگر `number_of_doctors` صفر بود، «۰ پزشک» نمایش داده شود (یا در صورت تمایل پنهان شود).
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- پاسخ double-nested است → `res.data.data`.
|
||||||
|
- `matchedCity?.id` ممکن است `null` باشد (دامنهی ناشناخته) → بدون `city_id` ارسال شود؛ بکاند شمارش سراسری میدهد (fallback).
|
||||||
|
- لینک کارت تخصص از `data.id` استفاده میکند (`/doctors?specialties=<id>`) — مطمئن شو `id` در پاسخ API همان است که `/doctors` با `specialty_id`/`specialties` میپذیرد.
|
||||||
|
- App Router؛ `generateMetadata` و `getStateInfo` الگوی موجود؛ RTL/Vazir.
|
||||||
|
- بعد از تغییر: `npm run build` بدون خطا؛ روی `/specialties` تعداد واقعی پزشکانِ شهر زیر هر تخصص دیده شود؛ جستجو همچنان کار کند.
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# ممیزی و رفع کامل Technical SEO با محوریت معماری چند-دامنهای
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
## نقش
|
||||||
|
|
||||||
|
تو یک متخصص ارشد Technical SEO با تسلط کامل بر Next.js 15 App Router (SSR/SSG/ISR/RSC/Streaming/PPR)، Metadata API، Core Web Vitals، Structured Data، Crawlability و International/Multi-domain SEO هستی. فقط از قابلیتهای رسمی و غیرمنسوخ Next.js استفاده کن و آخرین Best Practiceهای Google Search و Vercel را مبنا قرار بده.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
سایت نوبتدهی پزشکی «نوبت 724» یک deployment واحد Next.js 15 است که **دهها دامنه شهری مستقل** را سرو میکند (مثل `arak-nobat.ir`، `yazd-nobat.ir`، ...) بهعلاوه دامنه اصلی `nobat724.com`. تشخیص شهر از روی `host` header انجام میشود (`lib/getStateInfo.js` سمت سرور، `context/ProvinceProvider.js` سمت کلاینت). منبع داده دامنهها و متادیتای هر شهر `data/city.json` است (فیلدهای `domain`، `title`، `description`, `keywords`, `site_name`). زبان کل سایت فارسی، RTL، تقویم جلالی است. slug پزشک/کلینیک همان `uuid` است (`/doctor/${uuid}`).
|
||||||
|
|
||||||
|
وضعیت فعلی چند تناقض معماری جدی دارد که عملاً SEO دامنههای شهری را از کار میاندازد (شرح در «وضعیت فعلی»). هدف این پرامپت: ممیزی کامل، تصمیمگیری صریح درباره استراتژی چند-دامنهای، و پیادهسازی اصلاحات.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
1. تعیین تکلیف استراتژی canonical در معماری چند-دامنهای (مهمترین تصمیم — بقیه کارها به آن وابستهاند).
|
||||||
|
2. رفع مشکلات شناساییشده در sitemap، robots، metadata، JSON-LD، status codeها و rendering.
|
||||||
|
3. ممیزی سیستماتیک بقیه حوزهها (تصاویر، performance، pagination، accessibility، caching، internal linking) و رفع موارد یافتشده.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `app/layout.js` | metadata سطح ریشه، canonical، JSON-LD سازمان/وبسایت، viewport، GA |
|
||||||
|
| `lib/getCanonicalUrl.js` | تولید canonical — **همه دامنههای شهری را به دامنه اصلی canonical میکند** |
|
||||||
|
| `components/CanonicalHandler.js` | تزریق canonical سمت کلاینت با DOM (آنتیپترن) |
|
||||||
|
| `middleware.js` | تزریق `x-pathname` برای canonical |
|
||||||
|
| `app/sitemap.js` + `utils/sitemap.js` | sitemap داینامیک per-domain |
|
||||||
|
| `app/robots.js` | robots per-domain + گیت `DEV_MODE` |
|
||||||
|
| `next.config.js` | security headers (CSP/HSTS/...)، images remotePatterns |
|
||||||
|
| `app/doctor/[slug]/page.js` | صفحه پزشک: `generateMetadata` + JSON-LD Physician |
|
||||||
|
| `app/clinic/[slug]/page.js` | صفحه کلینیک: مشابه پزشک (MedicalClinic) |
|
||||||
|
| `app/blog/[slug]/page.js` | صفحه بلاگ (Article) |
|
||||||
|
| `app/doctors/page.js`، `app/clinics/page.js` | لیستهای فیلترشونده با searchParams |
|
||||||
|
| `app/page.js`، `app/specialties/page.js`، `app/blogs/page.js`، `app/about-us/page.js`، `app/contact-us/` | سایر صفحات عمومی |
|
||||||
|
| `data/city.json`، `data/state.json` | منبع دامنه/متادیتای شهرها |
|
||||||
|
| `lib/getStateInfo.js`، `lib/rootCity.js` | تشخیص شهر از host |
|
||||||
|
|
||||||
|
## وضعیت فعلی (یافتههای ممیزی اولیه — از کد واقعی)
|
||||||
|
|
||||||
|
### ۱. Canonical همه دامنههای شهری را نابود میکند (Critical)
|
||||||
|
|
||||||
|
`lib/getCanonicalUrl.js` هر دامنهای غیر از `nobat724.com` را به دامنه اصلی canonical میکند:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// lib/getCanonicalUrl.js
|
||||||
|
if (normalizedHost === "nobat724.com") {
|
||||||
|
return null; // No canonical needed for main domain
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
if (matchedCity || normalizedHost !== "nobat724.com") {
|
||||||
|
return `${MAIN_DOMAIN}${cleanPathname}`; // MAIN_DOMAIN = "https://nobat724.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
یعنی `arak-nobat.ir/doctors` به گوگل میگوید «نسخه اصلی من `nobat724.com/doctors` است» — هیچ دامنه شهری هرگز ایندکس/رتبه نمیگیرد. ضمناً:
|
||||||
|
- دامنه اصلی **هیچ self-canonical ندارد** (`return null`).
|
||||||
|
- شرط `matchedCity || normalizedHost !== "nobat724.com"` همیشه true است (شاخه دوم)، پس `matchedCity` بیاثر است.
|
||||||
|
- canonical فقط در layout ست میشود؛ صفحات dynamic (doctor/clinic/blog) که `generateMetadata` خودشان را دارند و `alternates` برنمیگردانند، متادیتای layout را override میکنند و **عملاً بدون canonical میمانند**.
|
||||||
|
- query stringها در canonical مدیریت نمیشوند (`x-pathname` فقط pathname است — صفحات پارامتری `/doctors?specialty=...` بدون canonical).
|
||||||
|
- `components/CanonicalHandler.js` با `useEffect` تگ canonical به DOM تزریق میکند — گوگل ممکن است ببیند یا نبیند؛ آنتیپترن و باید حذف شود.
|
||||||
|
|
||||||
|
### ۲. Sitemap (High)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// app/sitemap.js
|
||||||
|
const NOW = new Date(); // module-level — در build/بوت ثابت میشود؛ lastmod جعلی
|
||||||
|
function getCurrentDomain() {
|
||||||
|
const headersList = headers(); // Next 15: باید await شود
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `lastModified` برای doctor/clinic همیشه `NOW` است (جعلی — گوگل lastmod غیرقابلاعتماد را کلاً نادیده میگیرد).
|
||||||
|
- sitemap هر دامنه شهری **همه** پزشکان/کلینیکهای کشور را لیست میکند (fetch بدون فیلتر شهر) — در حالی که صفحات لیست همان دامنه به شهر فیلتر میشوند؛ تناقض با استراتژی چند-دامنهای.
|
||||||
|
- `headers()` بدون `await` (در Next 15 deprecated و در نسخههای بعدی میشکند).
|
||||||
|
- fetch با `limit=2000` تکصفحهای — بالای ۲۰۰۰ رکورد silent truncation.
|
||||||
|
- `utils/sitemap.js` دارای `escapeXml` و `createSitemapUrl` است که با Metadata API Route (`app/sitemap.js`) لازم نیستند (Next خودش escape میکند) — کد مرده/گمراهکننده.
|
||||||
|
|
||||||
|
### ۳. Robots (High)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// app/robots.js
|
||||||
|
return {
|
||||||
|
rules: { userAgent: '*', allow: '/', disallow: ['/dashboard'] },
|
||||||
|
sitemap: `${baseUrl}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- `/panel`، `/login`، `/login-verify`، `/payment`، `/appointment` (فلوی رزرو کاربر) disallow نشدهاند.
|
||||||
|
- `headers()` بدون `await`.
|
||||||
|
- در حالت `DEV_MODE=TRUE` مسیر sitemap هم حذف میشود (درست) ولی صفحات auth-gated فقط با robots بلاک میشوند، متای `noindex` per-page ندارند.
|
||||||
|
|
||||||
|
### ۴. Metadata سطح ریشه (High)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// app/layout.js
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
```
|
||||||
|
|
||||||
|
- viewport با تگ دستی بهجای `export const viewport` (روش رسمی Next 15).
|
||||||
|
- **`metadataBase` هیچجا تعریف نشده** — در معماری چند-دامنهای باید per-request از host ساخته شود تا URLهای نسبی OG/canonical درست resolve شوند.
|
||||||
|
- تصاویر OG/Twitter همه صفحات لوگوی `https://www.nobat724.com/assets/images/logo.png` است (با `www.` — در حالی که canonical بدون `www` است؛ ناسازگاری هاست).
|
||||||
|
- `keywords` meta استفاده شده (بیاثر برای گوگل؛ تصمیم بگیر نگهداری یا حذف).
|
||||||
|
- JSON-LD `Organization` و `WebSite` روی **همه دامنهها** به `https://www.nobat724.com` اشاره میکند — روی `arak-nobat.ir` داده ساختاریافته متعلق به دامنه دیگر است. `SearchAction` هم فقط به دامنه اصلی.
|
||||||
|
|
||||||
|
### ۵. صفحات dynamic — soft 404 و metadata ناقص (High)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// app/doctor/[slug]/page.js
|
||||||
|
const doctor = await getDoctor(slug);
|
||||||
|
if (!doctor) return {}; // metadata خالی
|
||||||
|
```
|
||||||
|
|
||||||
|
- وقتی پزشک پیدا نشود `generateMetadata` آبجکت خالی برمیگرداند؛ بررسی کن آیا کامپوننت صفحه `notFound()` صدا میزند یا با status 200 صفحه نیمهخالی رندر میشود (**soft 404**). الگوی درست: در صورت null بودن، `notFound()` در body صفحه.
|
||||||
|
- هیچکدام از صفحات doctor/clinic/blog در `generateMetadata` خود `alternates.canonical` برنمیگردانند.
|
||||||
|
- ISR با `revalidate: 3600` وجود دارد (خوب) — ولی صحت `dynamicParams` و رفتار برای slugهای نامعتبر باید بررسی شود.
|
||||||
|
|
||||||
|
### ۶. صفحات لیستی و پارامتری (Medium)
|
||||||
|
|
||||||
|
`app/doctors/page.js` با `searchParams` رندر SSR میشود (force-dynamic ضمنی). صفحات فیلترشده (`?specialty=...&state=...`) hیچ canonical/robots مشخصی ندارند → ریسک ایندکس بینهایت URL پارامتری duplicate. pagination (اگر با پارامتر `page` است) نه `rel prev/next` دارد نه canonical.
|
||||||
|
|
||||||
|
### ۷. Security headers — وضعیت خوب
|
||||||
|
|
||||||
|
`next.config.js` HSTS/X-Frame-Options/CSP/Referrer-Policy/Permissions-Policy دارد. فقط `'unsafe-inline' 'unsafe-eval'` در script-src از دید سختگیرانه ضعیف است — گزارش بده ولی تغییر آن اولویت SEO نیست.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۰. تصمیم استراتژی چند-دامنهای (پیشنیاز همهچیز)
|
||||||
|
|
||||||
|
دو استراتژی ممکن را مقایسه و **گزینه A را پیادهسازی کن** (مگر اینکه در حین کار شواهدی خلافش پیدا کنی؛ در آن صورت قبل از ادامه به کاربر گزارش بده):
|
||||||
|
|
||||||
|
- **گزینه A — دامنههای شهری first-class:** هر دامنه شهری self-canonical دارد و مستقل ایندکس میشود. لازمهاش: canonical per-host، sitemap فیلترشده به همان شهر، JSON-LD با URL همان دامنه، محتوای متمایز per-city (که با title/description/فیلتر شهریِ موجود در `city.json` و لیستهای فیلترشده فراهم است). این با معماری فعلی محصول (فیلتر خودکار شهر در `app/doctors/page.js` بر اساس دامنه) سازگار است.
|
||||||
|
- **گزینه B — تجمیع روی دامنه اصلی:** وضعیت فعلی canonical، ولی آنگاه وجود دامنههای شهری از نظر SEO بیمعناست.
|
||||||
|
|
||||||
|
خروجی این وظیفه: بازنویسی `lib/getCanonicalUrl.js` به یک `buildCanonical(pathname)` که **همیشه** self-canonical روی هاست جاری برمیگرداند (بدون www، lowercase، بدون trailing slash، بدون query string بهجز پارامترهای معنادار whitelisted). حذف کامل `components/CanonicalHandler.js` و همه usageهایش.
|
||||||
|
|
||||||
|
### ۱. `metadataBase` و canonical per-page
|
||||||
|
|
||||||
|
- در `app/layout.js` از host جاری `metadataBase` بساز:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const headersList = await headers();
|
||||||
|
const host = (headersList.get("host") || "nobat724.com").toLowerCase().replace(/^www\./, "");
|
||||||
|
const metadataBase = new URL(`https://${host}`);
|
||||||
|
// ... alternates: { canonical: pathname } — با metadataBase به URL مطلق resolve میشود
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- در `generateMetadata` تکتک صفحات (doctor/clinic/blog/doctors/clinics/specialties/blogs/about-us/contact-us/home) `alternates.canonical` نسبی اضافه کن (مثلاً `/doctor/${slug}`).
|
||||||
|
- صفحات پارامتری: canonical به نسخه بدون پارامتر (یا فقط با پارامترهای whitelisted مثل `specialty`).
|
||||||
|
- `viewport` را به `export const viewport` منتقل کن و تگ دستی را حذف کن.
|
||||||
|
|
||||||
|
### ۲. اصلاح sitemap
|
||||||
|
|
||||||
|
- `headers()` را `await` کن.
|
||||||
|
- sitemap هر دامنه شهری را به همان شهر فیلتر کن (همان پارامترهای `state`/`city` که `app/doctors/page.js` استفاده میکند)؛ دامنه اصلی همه را بگیرد. اگر API فیلتر شهر برای doctors/clinics دارد از همان استفاده کن — قرارداد را از `buildDoctorParams` در `helper` و `services/` استخراج کن.
|
||||||
|
- `lastModified` جعلی (`NOW`) را حذف کن: اگر فیلد تاریخ واقعی (`updated`/`created`) در پاسخ API هست استفاده کن، وگرنه `lastModified` را برای آن entry اصلاً نفرست.
|
||||||
|
- pagination فراخوانی API (بهجای `limit=2000` تکصفحه) یا حداقل log هشدار در truncation.
|
||||||
|
- توابع بلااستفاده `escapeXml`/`createSitemapUrl` در `utils/sitemap.js` را حذف یا مستند کن.
|
||||||
|
|
||||||
|
### ۳. اصلاح robots
|
||||||
|
|
||||||
|
- `await headers()`.
|
||||||
|
- disallow: `/panel`, `/dashboard`, `/login`, `/login-verify`, `/payment`, `/appointment` (بررسی کن `appointment` صفحه عمومی SEO-دار نباشد — اگر فلوی رزرو شخصی است بلاک شود).
|
||||||
|
- به صفحات auth-gated (panel/dashboard/login) `robots: { index: false }` per-page اضافه کن (layout آن route group).
|
||||||
|
|
||||||
|
### ۴. JSON-LD چند-دامنهای
|
||||||
|
|
||||||
|
- `Organization`/`WebSite` در `app/layout.js`: `url` و `SearchAction.target` را از host جاری بساز؛ `name` از `matchedCity.site_name`.
|
||||||
|
- صفحه doctor: schema `Physician` موجود را validate کن (فیلدهای `address`, `medicalSpecialty`, `image`, `url` مطلق روی دامنه جاری). clinic (`MedicalClinic`) و blog (`Article`) همینطور.
|
||||||
|
- `BreadcrumbList` به صفحات doctor/clinic/blog اضافه کن (خانه → لیست → آیتم) با URLهای دامنه جاری.
|
||||||
|
- خروجی JSON-LD را با `JSON.stringify(...).replace(/</g, '\\u003c')` یا sanitize موجود در `lib/sanitize.js` در برابر XSS امن کن.
|
||||||
|
|
||||||
|
### ۵. Soft 404 و status codes
|
||||||
|
|
||||||
|
- در `app/doctor/[slug]/page.js`، `app/clinic/[slug]/page.js`، `app/blog/[slug]/page.js`: اگر fetch نتیجه null داد `notFound()` صدا بزن (از `next/navigation`). `generateMetadata` در حالت null هم `robots: { index: false }` یا metadata حداقلی برگرداند.
|
||||||
|
- `app/not-found.js` و `app/error.js` را بررسی کن که متای noindex و status صحیح داشته باشند.
|
||||||
|
|
||||||
|
### ۶. ممیزی rendering و performance
|
||||||
|
|
||||||
|
- برای هر route مشخص کن الان static است یا dynamic (خروجی `npm run build` را بخوان). صفحات محتوایی (about-us, contact-us, specialties, blogs) نباید بیدلیل dynamic باشند — دقت کن `headers()` در layout همهچیز را dynamic میکند؛ این trade-off معماری چند-دامنهای است، مستندش کن و جایی که ممکن است ISR per-route حفظ شود.
|
||||||
|
- LCP: تصویر hero/بنر صفحه اصلی `priority` داشته باشد؛ استفاده از `next/image` را در کامپوننتهای اصلی (home, doctor card, doctor page) بررسی کن — هر `<img>` خام را گزارش و به `next/image` با `sizes` مناسب تبدیل کن (ابعاد مشخص → جلوگیری از CLS).
|
||||||
|
- فونت Vazir: `font-display: swap` موجود است؛ preload فایل woff2 اصلی را در layout اضافه کن.
|
||||||
|
- اسکریپت GA با `@next/third-parties` لود میشود (بهینه است — دست نزن).
|
||||||
|
|
||||||
|
### ۷. Pagination و صفحات پارامتری
|
||||||
|
|
||||||
|
- در لیست doctors/clinics: لینکهای صفحه بعد/قبل باید `<a href>` واقعی قابل crawl باشند (نه فقط onClick). بررسی کن `components/doctors/` چطور pagination میسازد؛ در صورت client-only بودن، به `<Link>` با href پارامتردار تبدیل کن.
|
||||||
|
- صفحات با فیلترهای ترکیبی: `robots: { index: false, follow: true }` برای ترکیبهای بیش از یک فیلتر، تا crawl budget هدر نرود (الگوی رایج سایتهای listing).
|
||||||
|
|
||||||
|
### ۸. گزارش نهایی ممیزی
|
||||||
|
|
||||||
|
برای هر مشکل یافتشده/رفعشده گزارش بده با: عنوان، علت، تأثیر بر SEO، اولویت (Critical/High/Medium/Low)، راهحل اعمالشده، فایلهای تغییرکرده. مواردی که عمداً تغییر ندادی (مثل `'unsafe-inline'` در CSP یا `keywords` meta) را با دلیل در بخش «بررسی شد — تغییر لازم نیست/تصمیم محصولی» لیست کن. حوزههای چکلیست که مشکلی نداشتند (mobile viewport، compress، poweredByHeader، HSTS و…) را هم یکخطی تأیید کن.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **همه تغییرات باید نسبت به host جاری relative باشند** — هیچ URL هاردکد `nobat724.com` در metadata/JSON-LD/sitemap باقی نماند مگر آگاهانه (لوگوی fallback OG اشکالی ندارد ولی ترجیحاً از دامنه جاری سرو شود اگر asset موجود است).
|
||||||
|
- ناسازگاری `www.nobat724.com` (در تصاویر OG و JSON-LD) با `nobat724.com` (در canonical) را یکدست کن — نسخه بدون `www` مبنا.
|
||||||
|
- Next.js 15: `params`، `searchParams` و `headers()` همگی **باید await شوند**.
|
||||||
|
- hreflang کاربردی ندارد (همه دامنهها fa-IR هستند) — بهجایش تمایز محتوایی per-city ملاک است؛ hreflang اضافه نکن.
|
||||||
|
- `DEV_MODE=TRUE` رفتار noindex سراسری دارد — این مکانیزم را نشکن؛ در همه تغییرات robots/metadata حفظش کن.
|
||||||
|
- تستهای موجود (`lib/getStateInfo.test.js`, `lib/multiDomainClient.test.js`, `services.test.js`, vitest) را بعد از تغییرات اجرا کن: `npm run test` (یا `npx vitest run`). برای `getCanonicalUrl` بازنویسیشده تست بنویس (هاست شهری، هاست اصلی، www، پورت dev، query string).
|
||||||
|
- تست دستی چند-دامنهای: dev server با `HOST=yazd-nobat.localhost` بالا میآید (`npm run dev`)؛ برای دامنه دیگر `HOST` را موقتاً عوض کن. خروجی `curl -s http://yazd-nobat.localhost:3000 | grep -i canonical` و `/sitemap.xml` و `/robots.txt` را برای حداقل دو هاست مقایسه کن.
|
||||||
|
- بعد از اتمام، `npm run build` باید بدون خطا پاس شود و در خروجی build بررسی کن هیچ صفحهای ناخواسته از static به dynamic (یا برعکس) جابهجا نشده باشد.
|
||||||
|
- استایل/زبان: همه رشتههای جدید فارسی، RTL؛ کد مطابق الگوهای موجود پروژه (jsx، بدون TypeScript).
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# صفحه عمومی پزشک — رفتار الزامی برای پروفایل بدون صاحب (unclaimed)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی) — **cross-repo**. پرامپت همتای backend:
|
||||||
|
`clinicpro/.claude/prompt/unclaimed-doctor-null-rating.md`
|
||||||
|
آن پرامپت backend را **اول** اجرا کن؛ بعد این. backend فیلد `point`/`satisfaction` را برای پروفایل غیر-claimed به `null` برمیگرداند و `owner_status` را در پاسخ میگذارد.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
پزشکانِ ایمپورتشده از نظام پزشکی با `owner_status = "unclaimed"` میآیند: بدون عکس واقعی، بدون بیوگرافی، بدون آدرس/تلفن واقعی، صفر نظر. اما صفحهی عمومی همان UI پزشک عادی را نشان میدهد — امتیاز پیشفرض جعلی، تیک تأیید، دکمهی نوبتدهی مرده، ایندکسشدنِ صفحهی نازک. این هم گمراهکننده است، هم برای سئو مضر (near-duplicate + thin content).
|
||||||
|
|
||||||
|
هدف: وقتی `doctor.owner_status !== "claimed"` مجموعهای از اصلاحات الزامی اعمال شود؛ و بهمحض claimed شدن، صفحه دقیقاً به حالت عادی برگردد (همهی این شرطها فقط با یک flag گیت میشوند، پس خودکار برمیگردند).
|
||||||
|
|
||||||
|
> شرط واحد در همهی وظایف: `const isUnclaimed = doctor?.owner_status !== "claimed";` (یعنی `unclaimed` یا `pending_transfer`). پروفایل موجودِ عادی `owner_status = "claimed"` دارد → هیچ تغییری نمیبیند.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `app/doctor/[slug]/page.js` | Server Component؛ fetch (`getDoctor` L13–22)، `generateMetadata` L39–80، JSON-LD `aggregateRating` L109–161 |
|
||||||
|
| `components/doctor/detailDoctor/Title.js` | نمایش امتیاز/رضایت (L69–78)، تیک تأیید `TickCircle` (L55–58) |
|
||||||
|
| `components/doctor/claim/index.js` | بخش claim موجود (بنر «این پروفایل بر اساس اطلاعات عمومی...»، گیت با `owner_status !== "unclaimed"` L44) |
|
||||||
|
| `components/doctor/detailDoctor/cards/comments/index.js` | بلوک نظرات |
|
||||||
|
| `app/sitemap.js` | `getDoctorUrls` L98–112 — همهی پزشکان بدون فیلتر |
|
||||||
|
| `app/robots.js` | robots سراسری |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
نمایش امتیاز (`Title.js` L69–78) — همیشه، حتی وقتی `point` جعلی است:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<span>امتیاز: {doctor?.point}</span>
|
||||||
|
...
|
||||||
|
<span>{doctor?.satisfaction}% رضایت کاربران</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
تیک تأیید (`Title.js` L55–58) — **بدون هیچ شرطی** برای همهی پزشکان:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<TickCircle className="hidden lg:flex ..." />
|
||||||
|
<span>کد نظام پزشکی: {doctor?.medical_system_code}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON-LD AggregateRating (`page.js` L123–130) — فقط با `point > 0` گیت شده:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
...(Number(doctor.point) > 0 && {
|
||||||
|
aggregateRating: {
|
||||||
|
"@type": "AggregateRating",
|
||||||
|
ratingValue: doctor.point,
|
||||||
|
bestRating: "5",
|
||||||
|
ratingCount: comments?.length || 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
`generateMetadata` (L39–80) — **هیچ `robots`/noindex ندارد**.
|
||||||
|
`sitemap.js` `getDoctorUrls` (L98–112) — برای هر پزشکِ دارای uuid یک `/doctor/${d.uuid}` میسازد، بدون فیلتر claim.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. مخفیکردن امتیاز/رضایت برای unclaimed (مهمترین)
|
||||||
|
|
||||||
|
در `Title.js`، بلوک امتیاز و رضایت (L69–78) فقط وقتی نمایش داده شود که امتیاز واقعی وجود دارد. چون backend اکنون `point`/`satisfaction` را `null` میکند:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{Number(doctor?.point) > 0 && (
|
||||||
|
// بلوک «امتیاز: ...» و «...% رضایت کاربران»
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
پس برای unclaimed (که `point === null`) اصلاً رندر نمیشود. مطمئن شو placeholder «بدون نمره»/«۰ رضایت» جایگزین نمیشود — کل بلوک حذف شود.
|
||||||
|
|
||||||
|
### ۲. حذف JSON-LD AggregateRating برای unclaimed
|
||||||
|
|
||||||
|
با null شدن `point` در backend، شرط موجود `Number(doctor.point) > 0` (page.js L123) خودش `aggregateRating` را حذف میکند. **تأیید کن** که با `point = null` این شرط false میشود و بلوک `review[]` (L131–138) هم وقتی نظر واقعی نیست خالی/حذف است. در صورت نیاز شرط را صریحتر کن:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
...(!isUnclaimed && Number(doctor.point) > 0 && { aggregateRating: {...} }),
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳. تیک تأیید فقط برای claimed
|
||||||
|
|
||||||
|
در `Title.js` L55–58، `TickCircle` را مشروط کن:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{!isUnclaimed && <TickCircle className="hidden lg:flex ..." />}
|
||||||
|
```
|
||||||
|
|
||||||
|
کد نظام پزشکی بماند، فقط علامت ✓ سبز برای غیر-claimed حذف شود.
|
||||||
|
|
||||||
|
### ۴. noindex روی صفحهی unclaimed
|
||||||
|
|
||||||
|
در `generateMetadata` (`page.js` L39–80) وقتی پروفایل claimed نیست، robots را noindex کن:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const isUnclaimed = doctor?.owner_status !== "claimed";
|
||||||
|
return {
|
||||||
|
// ... title/description/openGraph موجود
|
||||||
|
robots: isUnclaimed
|
||||||
|
? { index: false, follow: true }
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
(`follow: true` تا لینکهای داخلی دنبال شوند ولی خود صفحهی نازک ایندکس نشود.)
|
||||||
|
|
||||||
|
### ۵. خارجکردن unclaimed از sitemap
|
||||||
|
|
||||||
|
در `app/sitemap.js` `getDoctorUrls` (L98–112)، فقط پزشکان claimed را وارد کن:
|
||||||
|
|
||||||
|
```js
|
||||||
|
.filter((d) => d?.uuid && d?.owner_status === "claimed")
|
||||||
|
.map((d) => ({ url: `${base}/doctor/${d.uuid}`, ... }))
|
||||||
|
```
|
||||||
|
|
||||||
|
مطمئن شو پاسخ list (`/api/v1/doctors`, `toListArray`) فیلد `owner_status` دارد (طبق پرامپت backend دارد).
|
||||||
|
|
||||||
|
### ۶. برچسب «تأییدنشده» + CTA تصاحب
|
||||||
|
|
||||||
|
بخش claim موجود است (`components/doctor/claim/index.js`, mount در `components/doctor/index.js` L45) و با `owner_status === "unclaimed"` نمایش داده میشود. کارها:
|
||||||
|
- یک برچسب کوچک «تأییدنشده» نزدیک نام/تیتر در `Title.js` برای `isUnclaimed` اضافه کن (متنی خنثی، مثلاً badge خاکستری «پروفایل تأییدنشده»).
|
||||||
|
- بنر claim فعلی گیتش `!== "unclaimed"` است؛ اگر `pending_transfer` هم باید CTA ببیند، شرط را به `owner_status !== "claimed"` گسترش بده (هماهنگ با `isUnclaimed`). در غیر اینصورت بدون تغییر بماند.
|
||||||
|
|
||||||
|
### ۷. مخفیکردن بلوکهای خالی در حالت unclaimed
|
||||||
|
|
||||||
|
- بلوک نظرات (`cards/comments/index.js`): وقتی `isUnclaimed` و صفر نظر واقعی، بهجای «۰ از ۵ / بدون نمره» boilerplate، یا کل بلوک را مخفی کن یا یک متن زمینهای «هنوز نظری ثبت نشده» نشان بده. تکرارِ پنجبار «بدون نمره» حذف شود.
|
||||||
|
- بلوک موقعیت مکانی (`cards/locations/index.js`) already وقتی آدرس واقعی نیست `null` برمیگرداند (L5) — نیازی به تغییر نیست، فقط تأیید کن آدرسِ خودکار «مطب دکتر ...» بدون مختصات، map/تلفن جعلی نشان نمیدهد.
|
||||||
|
|
||||||
|
### ۸. (اختیاری، اولویت پایین) اسلاگ کلیدواژهدار
|
||||||
|
|
||||||
|
فعلاً slug === uuid (page.js L15, L113؛ sitemap L105؛ CTA `ItemAppointment.js` L57). تغییر به اسلاگ خوانا (مثل `/doctor/سیده-شهلا-حسینی-پاتولوژی-یاسوج`) تغییر بزرگ و ریسکدار است (باید redirect uuid→slug، تغییر canonical، هماهنگی با backend برای resolve اسلاگ). **در این پرامپت پیاده نکن** — فقط بهعنوان کار جدا یادداشت کن. اگر انجام شد، canonical و sitemap و لینکهای داخلی همه باید هماهنگ شوند و uuid همچنان resolve بماند.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **پاسخ API سهلایه است**: `getDoctor` با `json.data.data` میخواند (page.js L21). فیلد `owner_status` روی همین object داخلی است.
|
||||||
|
- **همهی شرطها با یک flag**: `owner_status !== "claimed"`. بهمحض claim شدن (backend → `'claimed'`), همهی این تغییرات خودکار غیرفعال و صفحه عادی میشود. هیچ منطق «reset» جدا لازم نیست.
|
||||||
|
- App Router: `generateMetadata` باید `params` را `await` کند (الگوی پروژه).
|
||||||
|
- دکمهی نوبتدهی («نوبتدهی غیرفعال») با `doctor.active`/`free_turn` گیت میشود، **نه** با claim. پزشکان ایمپورتشده در backend `active_doctor_appointment = true` دارند ولی slot واقعی ندارند → همان «غیرفعال» درست است. تغییرش نده مگر کاربر بخواهد.
|
||||||
|
- استایل: MUI v5 + Tailwind، RTL، Vazir. badge «تأییدنشده» را با همان توکنهای رنگی خنثی پروژه بساز.
|
||||||
|
- بعد از تغییر: `npm run build` برای صحت (بدون خطای type/lint).
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# ارتقای Next.js از 15 به 16 (آخرین نسخه) + فعالسازی امکانات جدید
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front`
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
پروژه روی `next@15.5.7` و `react@18.3.1` است (React با `overrides` در `package.json` روی 18 پین شده). آخرین نسخه پایدار Next.js نسخه **16.3.x** است (16.2.10 LTS هم موجود است — هدف این ارتقا `next@latest` یعنی 16.3.x). Next 16 چند breaking change دارد (React 19 اجباری، حذف `next lint`، deprecate شدن `middleware` به نفع `proxy`، Turbopack بهعنوان پیشفرض build/dev) و چند قابلیت جدید (Cache Components، React Compiler، Instant Navigations / Partial Prefetching در 16.3).
|
||||||
|
|
||||||
|
**تمام کار باید روی برنچ جدا انجام شود** — `main` دست نخورد.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
1. ارتقای `next` به آخرین نسخه (16.3.x) و `react`/`react-dom` به 19
|
||||||
|
2. رفع همه breaking changeها تا `npm run build` و `npm run test` سبز شود
|
||||||
|
3. فعالسازی امکانات جدید نسخه 16 که با معماری multi-domain پروژه سازگارند
|
||||||
|
4. رفتار SEO فعلی (متادیتا داخل `<head>`، canonical، sitemap، robots) عیناً حفظ شود
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `package.json` | نسخهها، `overrides` پین React 18، اسکریپت `next lint` |
|
||||||
|
| `next.config.js` | کانفیگ — شامل `htmlLimitedBots: /.*/` (فیکس SEO، **نباید حذف شود**)، `output: 'standalone'`، CSP headers |
|
||||||
|
| `middleware.js` | تزریق هدر `x-pathname` برای canonical — در 16 باید به `proxy.js` تبدیل شود |
|
||||||
|
| `app/layout.js` | `generateMetadata` مبتنی بر host (multi-domain) |
|
||||||
|
| `lib/getStateInfo.js`, `lib/getCanonicalUrl.js`, `lib/auth.js` | خواندن `headers()`/`cookies()` — ۱۰ فایل از `next/headers` استفاده میکنند |
|
||||||
|
| `app/doctor/[slug]/page.js`, `app/clinic/[slug]/page.js`, `app/blog/[slug]/page.js`, `app/sitemap.js` | fetch با `next: { revalidate: 3600, tags: [...] }` |
|
||||||
|
| `Dockerfile`, `nixpacks.toml`, `liara.json` | deploy — خروجی standalone |
|
||||||
|
| `vitest.config.mjs`, `test/` | تستها با vitest + @testing-library/react |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
```json
|
||||||
|
// package.json (بخشهای مرتبط)
|
||||||
|
"scripts": {
|
||||||
|
"dev": "cross-env HOST=yazd-nobat.localhost PORT=3000 NODE_TLS_REJECT_UNAUTHORIZED=0 next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "^15.5.7",
|
||||||
|
"@next/third-parties": "^15.5.7",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"@mui/material": "^5.18.0",
|
||||||
|
"@mui/x-charts": "^7.29.1",
|
||||||
|
"@mui/x-date-pickers": "^7.29.4"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// middleware.js — کل فایل
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware(request) {
|
||||||
|
const response = NextResponse.next();
|
||||||
|
response.headers.set('x-pathname', request.nextUrl.pathname);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
'/((?!api|_next/static|_next/image|favicon.ico|panel|dashboard|login|login-verify).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
نکات پیشبررسیشده (لازم نیست دوباره جستجو شوند):
|
||||||
|
|
||||||
|
- هیچ استفادهای از `legacyBehavior`، `useFormState`، `publicRuntimeConfig`/`serverRuntimeConfig`، `getInitialProps`، `next/legacy/image`، prop سفارشی `quality` روی `<Image>`، custom webpack config، یا `export const dynamic/revalidate` در پروژه **وجود ندارد**.
|
||||||
|
- `await params` از قبل در همه صفحات dynamic رعایت شده (الگوی Next 15).
|
||||||
|
- هیچ فایل کانفیگ ESLint وجود ندارد (`next lint` بدون config اجرا میشده) و `eslint` در devDependencies نیست.
|
||||||
|
- Node محلی v22 است (Next 16 حداقل 20.9 میخواهد — OK).
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. ساخت برنچ
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd nobat724_front
|
||||||
|
git checkout -b upgrade/next-16
|
||||||
|
```
|
||||||
|
|
||||||
|
همه commitها روی این برنچ. به `main` هیچ چیزی push نشود.
|
||||||
|
|
||||||
|
### ۲. ارتقای پکیجها
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @next/codemod@latest upgrade latest
|
||||||
|
```
|
||||||
|
|
||||||
|
اگر codemod به هر دلیل کامل اجرا نشد، دستی:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install next@latest react@latest react-dom@latest @next/third-parties@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
- بخش `overrides` (پین React 18) از `package.json` **حذف شود** — دلیل پین، سازگاری قدیمی MUI بود؛ `@mui/material@5.18` و `@mui/x-*@7.29` هر دو peer-dep React 19 را پشتیبانی میکنند.
|
||||||
|
- اگر پکیجهای قدیمی (`react-google-map-picker`، `react-images-uploading`، `aos`) خطای peer-dep برای React 19 دادند: اول `npm install --legacy-peer-deps` را امتحان کن و در گزارش نهایی صریح ذکر کن؛ سپس در مرحله تست، صفحات استفادهکننده از این پکیجها را در مرورگر چک کن (map picker در پنل، آپلود عکس، انیمیشنهای AOS صفحه اصلی).
|
||||||
|
- `@testing-library/react@16` و `vitest@2` با React 19 سازگارند؛ اگر تستها خطای rendering دادند فقط نسخه `@testing-library/react` را به آخرین minor ارتقا بده.
|
||||||
|
|
||||||
|
### ۳. تبدیل `middleware.js` به `proxy.js`
|
||||||
|
|
||||||
|
در Next 16 نام `middleware` deprecated و جایگزینش `proxy` است. فایل `middleware.js` را به `proxy.js` تغییر نام بده و تابع را rename کن:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// proxy.js
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export function proxy(request) {
|
||||||
|
const response = NextResponse.next();
|
||||||
|
response.headers.set('x-pathname', request.nextUrl.pathname);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
'/((?!api|_next/static|_next/image|favicon.ico|panel|dashboard|login|login-verify).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**تست حیاتی بعد از این تغییر:** هدر `x-pathname` باید همچنان برسد — `curl -s http://yazd-nobat.localhost:3000/doctors | grep canonical` باید `https://nobat724.com/doctors` بدهد (اگر header نرسد، `getCanonicalUrl` مقدار `null` برمیگرداند و تگ canonical حذف میشود — این یعنی regression).
|
||||||
|
|
||||||
|
### ۴. جایگزینی `next lint`
|
||||||
|
|
||||||
|
`next lint` در Next 16 حذف شده. چون هیچ کانفیگ ESLint موجود نیست:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D eslint eslint-config-next
|
||||||
|
```
|
||||||
|
|
||||||
|
فایل `eslint.config.mjs` (flat config) بساز:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { FlatCompat } from '@eslint/eslintrc';
|
||||||
|
|
||||||
|
const compat = new FlatCompat({ baseDirectory: import.meta.dirname });
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...compat.extends('next/core-web-vitals'),
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
و در `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"lint": "eslint app components lib services hooks context utils helper"
|
||||||
|
```
|
||||||
|
|
||||||
|
خطاهای lint موجود را فیکس نکن (خارج از scope) — فقط مطمئن شو دستور اجرا میشود؛ در صورت نیاز سطح خطاهای پرتکرار را در config به `warn` تنزل بده.
|
||||||
|
|
||||||
|
### ۵. Turbopack (پیشفرض جدید dev و build)
|
||||||
|
|
||||||
|
Next 16 هم `next dev` و هم `next build` را با Turbopack اجرا میکند. پروژه custom webpack config ندارد، پس انتظار سازگاری میرود. فقط verify کن:
|
||||||
|
|
||||||
|
- `npm run dev` بالا بیاید و صفحه اول، `/doctors`، `/doctor/[slug]` (یک uuid واقعی از خروجی `/doctors`) و `/panel` بدون خطای کنسول رندر شوند
|
||||||
|
- `npm run build` موفق شود و خروجی `standalone` تولید کند (برای Docker/liara لازم است — `ls .next/standalone`)
|
||||||
|
- استایلهای emotion RTL (`stylis-plugin-rtl` در `mui/index.js` یا `ThemeRegistry`) درست کار کنند — یک صفحه را چک کن که دکمهها/فرمها RTL باشند
|
||||||
|
|
||||||
|
اگر Turbopack با emotion/styled-components مشکل داشت، ابتدا `compiler.styledComponents: true` را در `next.config.js` امتحان کن؛ فقط بهعنوان آخرین راهحل build را با فلگ `--webpack` برگردان و دلیل را در گزارش بنویس.
|
||||||
|
|
||||||
|
### ۶. فعالسازی React Compiler
|
||||||
|
|
||||||
|
قابلیت پایدار نسخه 16 (و در 16.3 پشتیبانی Rust در Turbopack):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D babel-plugin-react-compiler
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// next.config.js
|
||||||
|
const nextConfig = {
|
||||||
|
reactCompiler: true,
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
بعد از فعالسازی، `npm run build` و تستهای vitest باید سبز بمانند. اگر کامپایلر روی کامپوننت خاصی خطا داد (کامپوننتهای کلاسی یا الگوهای غیراستاندارد)، همان کامپوننت را با `"use no memo"` مستثنا کن — کل قابلیت را خاموش نکن.
|
||||||
|
|
||||||
|
### ۷. Cache Components — ارزیابی محتاطانه (فعالسازی مشروط)
|
||||||
|
|
||||||
|
`cacheComponents: true` مدل کش جدید نسخه 16 است (`"use cache"`, `cacheLife`, `cacheTag`) و پیشنیاز Partial Prefetching در 16.3.
|
||||||
|
|
||||||
|
**هشدار معماری:** این پروژه multi-domain است — `generateMetadata` و layout و ده فایل دیگر به `headers()` (هدر `host`) وابستهاند؛ خروجی هر صفحه per-host متفاوت است. با `cacheComponents`، هر چیزی که `headers()` میخواند dynamic میماند و کش کردنش باعث نشت محتوای یک شهر به دامنه شهر دیگر میشود.
|
||||||
|
|
||||||
|
ترتیب کار:
|
||||||
|
|
||||||
|
1. ابتدا **بدون** فعالسازی، fetchهای موجود را دستنخورده بگذار — الگوی `next: { revalidate: 3600, tags: [...] }` در Next 16 همچنان پشتیبانی میشود
|
||||||
|
2. `cacheComponents: true` را روی یک commit جدا فعال کن و `npm run build` بگیر
|
||||||
|
3. اگر build خطای «uncached data access» برای مسیرهای متکی به `headers()` داد و رفعش نیاز به `Suspense`گذاری گسترده یا `"use cache"` روی توابع host-dependent داشت → **فعالسازی را revert کن** و در گزارش نهایی بنویس چرا (این قابلیت برای این معماری فعلاً امن نیست)
|
||||||
|
4. اگر build پاس شد → با دو host تست کن (`HOST=yazd-nobat.localhost` و یک شهر دیگر از `data/city.json` مثلاً با تغییر موقت HOST در اسکریپت dev) و مطمئن شو title/description/canonical هر دامنه مستقل است
|
||||||
|
|
||||||
|
### ۸. تمیزکاریهای کانفیگ نسخه 16
|
||||||
|
|
||||||
|
در `next.config.js`:
|
||||||
|
|
||||||
|
- `htmlLimitedBots: /.*/` **باید بماند** — فیکس عمدی برای رندر متادیتا داخل `<head>` است (streaming metadata خاموش). بعد از ارتقا با `curl -s -A "Mozilla/5.0" http://yazd-nobat.localhost:3000/ | python3 -c "import sys; h=sys.stdin.read(); e=h.find('</head>'); print('HEAD' if 0<=h.find('rel=\"canonical\"')<e else 'BODY')"` تأیید کن هنوز `HEAD` است
|
||||||
|
- `images.minimumCacheTTL` در 16 پیشفرض ۴ ساعت شده — نیازی به تغییر نیست، فقط بدان
|
||||||
|
- `env.DEV_MODE` و CSP headers و `output: 'standalone'` دست نخورند
|
||||||
|
|
||||||
|
### ۹. تست نهایی و commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test # همه تستهای vitest سبز
|
||||||
|
npm run build # build موفق با Turbopack
|
||||||
|
npm run lint # اجرای eslint بدون crash
|
||||||
|
```
|
||||||
|
|
||||||
|
بعد smoke test دستی با dev server:
|
||||||
|
|
||||||
|
- `/` — صفحه اصلی، جستجو، تصاویر next/image
|
||||||
|
- `/doctors` و `/doctor/[uuid]` — لیست و صفحه پزشک + JSON-LD
|
||||||
|
- `/blogs` و `/blog/[slug]`
|
||||||
|
- `/login` — فلوی OTP (فقط رندر فرم؛ ارسال واقعی لازم نیست)
|
||||||
|
- view-source صفحه اول: متادیتا (`keywords`, `canonical`, `og:*`, `twitter:*`) داخل `<head>`
|
||||||
|
|
||||||
|
Commitها را مرحلهای بزن (ارتقا پکیجها / proxy / eslint / react compiler / cache components هرکدام جدا) تا در صورت مشکل، revert تکی ممکن باشد.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **برنچ:** همهچیز روی `upgrade/next-16`؛ merge به `main` با کاربر است، خودت merge نکن.
|
||||||
|
- **SEO regression خط قرمز است:** canonical به `https://nobat724.com/<path>`، متادیتا در `<head>`، `robots.js` و `sitemap.js` باید عیناً مثل قبل کار کنند.
|
||||||
|
- ده فایل از `next/headers` استفاده میکنند — همه `await headers()` هستند (الگوی 15)؛ در 16 هم همین درست است.
|
||||||
|
- `services/response.js` قرارداد API با backend (`clinicpro`) است — این ارتقا نباید هیچ تغییری در آن بدهد.
|
||||||
|
- اسکریپت dev به `NODE_TLS_REJECT_UNAUTHORIZED=0` و `HOST` سفارشی وابسته است — بدون تغییر بماند.
|
||||||
|
- اگر در حین کار مستندات لازم شد: راهنمای رسمی مهاجرت `https://nextjs.org/docs/app/guides/upgrading/version-16` و بلاگهای `nextjs.org/blog/next-16`, `next-16-1`, `next-16-2`, و 16.3.
|
||||||
|
- بعد از اتمام: `graphify update .` در روت workspace اجرا شود.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# همخوانکردن مصرف پروفایل کاربر با endpoint اصلاحشده (بدون 404 برای کاربر جدید)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — سایت عمومی. **بعد از پرامپت backend اجرا شود.**
|
||||||
|
|
||||||
|
> **Cross-repo:** وابسته به اصلاح `GET /api/v1/user-profile/{uuid}` در
|
||||||
|
> `clinicpro/.claude/prompt/user-profile-resolve-by-user-uuid.md`
|
||||||
|
> که بعد از آن، endpoint با **user-uuid** هم کار میکند و برای کاربر بدون پروفایل، پروفایل خالی (200) برمیگرداند بهجای 404.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
`getUserProfile(uuid)` در سه جا با **uuid کاربر** (از کوکی) صدا زده میشود:
|
||||||
|
`components/appointment/index.js` (دو جا) و `components/dashboard/userAccount/detailUser/information/index.js`. قبل از اصلاح backend، این فراخوانی برای کاربر تازهلاگینکرده 404 میداد و کد مجبور بود حالت 404 را بهصورت ویژه هندل کند (فیلدها قابلویرایش، خالی). بعد از اصلاح backend، همان فراخوانی **200 با پروفایل خالی** برمیگرداند و دیگر 404ای در کار نیست.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
۱. تأیید کن که با endpoint اصلاحشده، مرحلهی Detail نوبت و داشبورد، پروفایل را درست لود میکنند (دیگر 404 نمیگیرند).
|
||||||
|
۲. منطق ویژهی 404 که دیگر لازم نیست را ساده کن (بدون شکستن حالت «پروفایل خالی»).
|
||||||
|
۳. شکل پاسخ را با backend همخوان نگهدار: پاسخ `{ success, data: {...profile...} }` است → بعد از interceptor، پروفایل در `res.data`.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/appointment/index.js` | `getUserProfile(parsedData.uuid)` در mount و step 3؛ منطق 404 موجود |
|
||||||
|
| `components/appointment/detail/SubmitData.js` | بعد از تغییر اطلاعات، PATCH/POST پروفایل (`uuid` پروفایل لازم است) |
|
||||||
|
| `components/dashboard/userAccount/detailUser/information/index.js` | `getUserProfile(parsedUserInfo.uuid)` |
|
||||||
|
| `services/response.js` | `getUserProfile`, `postUserProfile`, `patchUserProfile` |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
### `components/appointment/index.js` — هندل ویژهی 404
|
||||||
|
|
||||||
|
```js
|
||||||
|
const res = await request.getUserProfile(parsedData.uuid);
|
||||||
|
if (res?.data) {
|
||||||
|
const newData = buildProfileData(res.data, usernameFromCookie);
|
||||||
|
setData(newData);
|
||||||
|
setPrevData(newData);
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.response?.status === 404) {
|
||||||
|
setData(prev => ({ ...prev, national_code: { value: "", isEdit: true }, ... })); // دیگر لازم نیست
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `SubmitData.js` — انتخاب POST یا PATCH بر اساس وجود `uuid`
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (uuid) {
|
||||||
|
await request.patchUserProfile(payload, uuid); // uuid پروفایل
|
||||||
|
} else {
|
||||||
|
await request.postUserProfile(payload);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `uuid` اینجا از `data.uuid` میآید که در `buildProfileData(profile)` برابر `profile.uuid` (uuid پروفایل) ست میشود. حالا که backend برای کاربر جدید پروفایل خالی (با `uuid` واقعی پروفایل) برمیگرداند، `data.uuid` همیشه پر است و مسیر PATCH درست کار میکند.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. تأیید جریان لود پروفایل (appointment + dashboard)
|
||||||
|
|
||||||
|
- مطمئن شو هر سه فراخوانی `getUserProfile(uuid)` با **uuid کاربر** (از کوکی) کار میکنند و `res.data` پروفایل را میدهد.
|
||||||
|
- `buildProfileData(res.data, ...)` باید `uuid` پروفایل را از `res.data.uuid` بگیرد (نه user uuid) تا PATCH بعدی درست باشد. بررسی کن `buildProfileData` فیلد `uuid` را از `profile.uuid` میخواند (پاسخ backend شامل `uuid` پروفایل و `user_uuid` است).
|
||||||
|
|
||||||
|
### ۲. سادهسازی هندل 404 منسوخ
|
||||||
|
|
||||||
|
- در `components/appointment/index.js`، بلوک `if (error?.response?.status === 404) { ... }` در هر دو `useEffect` دیگر لازم نیست (backend دیگر 404 نمیدهد). آن را حذف کن یا به یک هندل خطای عمومی ساده تبدیل کن (toast سراسری از قبل خطاها را نشان میدهد). حالت «پروفایل خالی» حالا از خود پاسخ 200 میآید، نه از catch.
|
||||||
|
- مراقب باش منطق `defaultData`/`prevData` نشکند: اگر `res.data` فیلدهای null دارد، `buildProfileData` باید آنها را به `{ value: "", isEdit: true }` تبدیل کند (همان رفتار فعلی برای فیلدهای خالی).
|
||||||
|
|
||||||
|
### ۳. سازگاری POST/PATCH در `SubmitData.js`
|
||||||
|
|
||||||
|
- چون backend حالا پروفایل را lazy-create میکند، `data.uuid` برای کاربرِ «خودش» همیشه پر است → مسیر `patchUserProfile(payload, uuid)` طی میشود. مطمئن شو این درست است و مسیر `postUserProfile` (که ممکن بود 409 بدهد چون پروفایل از قبل ساخته شده) دیگر طی نمیشود مگر واقعاً `uuid` نباشد.
|
||||||
|
- اگر `postUserProfile` به هر دلیل 409 داد (پروفایل از قبل هست)، آن را به PATCH تبدیل کن یا نادیده بگیر (نه خطای کاربر).
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **وابستگی cross-repo:** بدون اصلاح backend، این هندلها هنوز 404 میگیرند. اگر backend هنوز اصلاح نشده، **اول آن را اجرا کن**.
|
||||||
|
- **شکل پاسخ:** `{ success, data: {...} }` → پروفایل در `res.data` (interceptor یکبار باز میکند). `res.data.uuid` = uuid پروفایل (برای PATCH)، `res.data.user_uuid` = uuid کاربر.
|
||||||
|
- **توهمسازی نکن:** فیلدهای null پروفایل را خالی و قابلویرایش نشان بده، نه مقدار جعلی.
|
||||||
|
- RTL/Jalali/multi-domain حفظ شوند.
|
||||||
|
- **تست:** `npm run build`؛ سپس دستی با کاربر `09210651788` (که پروفایل نداشت): بعد از لاگین، مرحلهی Detail نوبت و صفحهی داشبورد باید **بدون 404** فرم خالی قابلویرایش نشان دهند؛ ذخیره (PATCH) باید کار کند. سپس commit.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# تأیید و سختسازی غیرفعالبودن اسلاتهای گذشته در صفحهی نوبت
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` — سایت عمومی. **بعد از پرامپت backend اجرا شود.**
|
||||||
|
|
||||||
|
> **Cross-repo:** وابسته به اصلاح `is_available` در backend:
|
||||||
|
> `clinicpro/.claude/prompt/mark-past-slots-unavailable.md`
|
||||||
|
> بعد از آن، اسلاتهای گذشتهی روز جاری از API با `is_available: false` میآیند و UI خودکار آنها را خاکستری میکند. این پرامپت آن را تأیید و یک گارد سبک سمت کلاینت اضافه میکند.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
گرید ساعت (`List.js`) از قبل دکمه را با `disabled={!item.is_available}` غیرفعال و خاکستری میکند. پس وقتی backend اسلات گذشته را `is_available: false` بدهد، **بدون تغییر فرانت** خاکستری میشود. تنها ریسک باقیمانده: کاربر صفحه را ساعت ۹:۵۹ باز کرده، اسلات ۱۰:۰۰ آن لحظه `is_available: true` بوده و در state مانده؛ ساعت ۱۰:۰۱ همان آبجکت قدیمی هنوز کلیکپذیر است چون داده دوباره fetch نشده. backend موقع ثبت رد میکند (۴۲۲)، ولی بهتر است UI هم همان لحظه جلوی کلیک را بگیرد.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
۱. تأیید کن گرید ساعت `is_available` را درست رعایت میکند (انتظار: نیازی به تغییر نیست).
|
||||||
|
۲. یک گارد سبک سمت کلاینت اضافه کن: اسلاتی که `start` (Unix ثانیه) آن از «اکنون» گذشته، حتی اگر `is_available: true` آمده باشد، در UI غیرفعال شود.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `app/component/date/dateTime/hours/List.js` | گرید ساعت؛ `disabled={!item.is_available}` |
|
||||||
|
| `lib/appointmentSlots.js` | adapter اسلاتها (`sessions[]` → morning/evening) — اسلاتها `start`/`is_available` دارند |
|
||||||
|
| `app/component/date/dateTime/SendAppo.js` | دکمهی «تایید نوبت»؛ `disabled={!hour}` |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
### `hours/List.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
<Button
|
||||||
|
onClick={() => setHour(item)}
|
||||||
|
disabled={!item.is_available}
|
||||||
|
...
|
||||||
|
>
|
||||||
|
...
|
||||||
|
<p className={`... ${item.is_available ? "text-[#525252]" : "text-[#D7D7D7]"} ...`}>
|
||||||
|
{item.start_time}
|
||||||
|
</p>
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### آبجکت اسلات (از `adaptSlots` / API)
|
||||||
|
|
||||||
|
```js
|
||||||
|
{ start: 1781933400, end: 1781934600, start_time: "09:00", end_time: "09:20", location_id: 2581, is_available: true }
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. گارد زمان گذشته در گرید ساعت
|
||||||
|
|
||||||
|
در `List.js` یک محاسبهی کوچک اضافه کن که اسلات گذشته را غیرقابلانتخاب کند (مبنا: `item.start` بر حسب ثانیه):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const isPast = (item) => item?.start && item.start * 1000 < Date.now();
|
||||||
|
const isSelectable = (item) => item.is_available && !isPast(item);
|
||||||
|
```
|
||||||
|
|
||||||
|
سپس در دکمه `disabled={!isSelectable(item)}` و در کلاس متن `isSelectable(item) ? ... : ...` را جایگزین `item.is_available` کن.
|
||||||
|
|
||||||
|
> `start` ثانیه است؛ `Date.now()` میلیثانیه — پس `start * 1000`.
|
||||||
|
|
||||||
|
### ۲. (اختیاری) عدم انتخاب اسلات گذشته در SendAppo
|
||||||
|
|
||||||
|
اگر منطقی بود، در `SendAppo` هم قبل از `setSelectedSlot(hour)` مطمئن شو `hour.start * 1000 >= Date.now()`؛ در غیر این صورت اجازه نده مرحله جلو برود. (اگر گارد گرید کافی است و کاربر اصلاً نمیتواند اسلات گذشته را انتخاب کند، این مرحله را رد کن و در گزارش ذکر کن.)
|
||||||
|
|
||||||
|
### ۳. تأیید رفتار end-to-end
|
||||||
|
|
||||||
|
- با `date=<امروز>` و یک ساعت که چند اسلات اول روز گذشتهاند، تأیید کن اسلاتهای گذشته خاکستری و غیرقابلکلیکاند و اسلاتهای آینده فعال.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **بدون تایمر/رندر مکرر اضافه نکن** مگر لازم باشد؛ یک محاسبه در زمان رندر کافی است (هر بار state تغییر کند یا کامپوننت رندر شود ارزیابی میشود). نیازی به `setInterval` برای بهروزرسانی هر ثانیه نیست مگر کاربر بخواهد.
|
||||||
|
- **منبع اصلی همچنان backend است:** این گارد فقط لبهی «اسلاتی که همین حالا گذشت» را میگیرد. اگر backend درست `is_available:false` بدهد، این گارد در عمل کماثر ولی بیضرر و درست است.
|
||||||
|
- **تایمزون:** `start` از API بر حسب Unix ثانیه (UTC مبنا) است؛ `Date.now()` هم Unix میلیثانیه است — مقایسه درست است بدون تبدیل تایمزون.
|
||||||
|
- RTL/Jalali/multi-domain را خراب نکن؛ منطق `morning`/`evening` و adapter دستنخورده بماند.
|
||||||
|
- تست: `npm run build` و سپس بررسی دستی روی صفحهی نوبت پزشک تست. سپس commit با پیام توصیفی.
|
||||||
+4
-4
@@ -14,10 +14,10 @@ build
|
|||||||
coverage
|
coverage
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
|
||||||
# Environment files
|
# Environment files — never bake secrets into the image; pass via build args / runtime env
|
||||||
.env*.local
|
.env
|
||||||
.env.development
|
.env.*
|
||||||
.env.test
|
!.env.example
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode
|
.vscode
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Liara env vars — nobat724_front (Next.js platform)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# NEXT_PUBLIC_* are baked into the client bundle AT BUILD TIME, so set them in the
|
||||||
|
# Liara console BEFORE the first deploy; changing them later needs a REDEPLOY
|
||||||
|
# (not just a restart). Set via console (Environment tab) or:
|
||||||
|
# liara env:set KEY=VALUE --app nobat724
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Backend API base — all /api/v1/* and /oauth/token calls hit this origin.
|
||||||
|
NEXT_PUBLIC_API_URL=https://clinic-pro.ir
|
||||||
|
|
||||||
|
# FALSE → allow search-engine indexing (production). TRUE → noindex + block crawlers (staging).
|
||||||
|
DEV_MODE=FALSE
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Liara upload excludes. Liara runs `npm install` + `npm run build` itself, so
|
||||||
|
# local build artifacts and deps are not uploaded.
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
|
||||||
|
# Docker / Coolify artifacts — unused on the Next.js platform.
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
docker-compose.yml
|
||||||
|
|
||||||
|
# Local env & secrets — set these in the Liara console (build-time NEXT_PUBLIC_*).
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Tests, editor, runtime noise.
|
||||||
|
tests
|
||||||
|
__tests__
|
||||||
|
vitest.config.*
|
||||||
|
coverage
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -17,8 +17,6 @@ The dev script forces `HOST=yazd-nobat.localhost` so multi-domain detection work
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
NEXT_PUBLIC_API_URL=https://api.clinic-pro.ir # Backend API base URL
|
NEXT_PUBLIC_API_URL=https://api.clinic-pro.ir # Backend API base URL
|
||||||
NEXT_PUBLIC_CLIENT_ID=... # OAuth client ID
|
|
||||||
NEXT_PUBLIC_CLIENT_SECRET=... # OAuth client secret
|
|
||||||
DEV_MODE=TRUE # TRUE → blocks all crawlers + noindex
|
DEV_MODE=TRUE # TRUE → blocks all crawlers + noindex
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+71
-29
@@ -1,48 +1,90 @@
|
|||||||
# Dockerfile for Next.js 14 - Optimized for Production
|
# syntax=docker/dockerfile:1
|
||||||
|
# Multi-stage Dockerfile for Next.js 16 (App Router, output: 'standalone').
|
||||||
|
# Based on the official Next.js Docker example, tuned for Coolify.
|
||||||
|
# References:
|
||||||
|
# https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
|
||||||
|
# https://coolify.io/docs/applications/nextjs
|
||||||
|
|
||||||
# Use the official Node.js image with Alpine Linux for a smaller footprint
|
# ----------------------------------------------------------------------------
|
||||||
FROM node:22-alpine AS base
|
# Base image
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
# Set the working directory in the container
|
ARG NODE_VERSION=22
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS base
|
||||||
|
# libc6-compat is recommended by the Next.js image for some native deps (sharp, etc.)
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package.json and package-lock.json (or yarn.lock)
|
# ----------------------------------------------------------------------------
|
||||||
COPY package*.json ./
|
# 1. Dependencies — installed in a dedicated layer so they are cached as long
|
||||||
|
# as package.json / lockfile / .npmrc do not change.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package.json package-lock.json .npmrc ./
|
||||||
|
# Reproducible install honouring the lockfile (.npmrc provides legacy-peer-deps,
|
||||||
|
# required because react-google-map-picker declares a React 17 peer).
|
||||||
|
# --include=dev forces devDependencies even when the platform injects
|
||||||
|
# NODE_ENV=production (Coolify does this on all stages); the build needs
|
||||||
|
# devDeps such as babel-plugin-react-compiler (React Compiler) and typescript.
|
||||||
|
RUN npm ci --include=dev --no-audit --no-fund
|
||||||
|
|
||||||
# Install dependencies - leveraging Docker cache
|
# ----------------------------------------------------------------------------
|
||||||
RUN npm i --force
|
# 2. Builder — produces the standalone output.
|
||||||
|
# NEXT_PUBLIC_* values are inlined into the client bundle at BUILD time,
|
||||||
|
# so they must be passed as build args. DEV_MODE is read in app/layout.js
|
||||||
|
# to toggle Google Analytics + robots noindex, so it must also be present
|
||||||
|
# at build time.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy the rest of the application code
|
# Build-time arguments (Coolify: set these as Build Variables)
|
||||||
|
ARG NEXT_PUBLIC_API_URL
|
||||||
|
ARG DEV_MODE=FALSE
|
||||||
|
|
||||||
|
# Expose them to `next build`
|
||||||
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
|
ENV DEV_MODE=$DEV_MODE
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the Next.js application
|
# Next 16 builds with Turbopack by default; output: 'standalone' emits
|
||||||
|
# .next/standalone/server.js plus a trimmed node_modules.
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Production image - smaller and leaner
|
# ----------------------------------------------------------------------------
|
||||||
FROM node:22-alpine AS runner
|
# 3. Runner — minimal production image running the standalone server.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
# Set the working directory
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Set environment variables
|
ENV NODE_ENV=production
|
||||||
ENV NODE_ENV $NODE_ENV
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
# The standalone server must bind to all interfaces inside the container.
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
# Add a non-root user for security
|
# Run as a non-root user for security.
|
||||||
RUN addgroup -g 1001 -S nodejs
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
RUN adduser -S nextjs -u 1001
|
&& adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Copy only the necessary files from the builder stage
|
# Public assets (served by the standalone server).
|
||||||
COPY --from=base /app/next.config.js ./
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=base /app/public ./public
|
|
||||||
COPY --from=base --chown=nextjs:nodejs /app/.next/standalone ./
|
# Standalone output already contains a minimal node_modules + server.js.
|
||||||
COPY --from=base --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
# Static assets are NOT included in standalone and must be copied separately.
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
# Change ownership of all copied files to the non-root user
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
# Expose the port Next.js listens on
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Command to start the Next.js server in production mode
|
# Container-level health probe (also referenced by docker-compose / Coolify).
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD node -e "require('http').get('http://127.0.0.1:3000/',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
|
||||||
|
|
||||||
|
# server.js is generated by Next.js at the root of the standalone output.
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
@@ -282,8 +282,6 @@ cp .env.example .env.local
|
|||||||
# 4. تنظیم متغیرهای محیطی
|
# 4. تنظیم متغیرهای محیطی
|
||||||
# ویرایش .env.local:
|
# ویرایش .env.local:
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||||
NEXT_PUBLIC_CLIENT_ID=your_client_id
|
|
||||||
NEXT_PUBLIC_CLIENT_SECRET=your_client_secret
|
|
||||||
DEV_MODE=TRUE
|
DEV_MODE=TRUE
|
||||||
|
|
||||||
# 5. اجرای پروژه در حالت Development
|
# 5. اجرای پروژه در حالت Development
|
||||||
@@ -696,8 +694,6 @@ export function Providers({ children }) {
|
|||||||
```env
|
```env
|
||||||
# API Configuration
|
# API Configuration
|
||||||
NEXT_PUBLIC_API_URL=https://api.nobat724.com
|
NEXT_PUBLIC_API_URL=https://api.nobat724.com
|
||||||
NEXT_PUBLIC_CLIENT_ID=your_client_id
|
|
||||||
NEXT_PUBLIC_CLIENT_SECRET=your_client_secret
|
|
||||||
|
|
||||||
# Development Mode (TRUE/FALSE)
|
# Development Mode (TRUE/FALSE)
|
||||||
DEV_MODE=FALSE
|
DEV_MODE=FALSE
|
||||||
|
|||||||
+13
-3
@@ -1,14 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import { usePathname } from "next/navigation";
|
import { useEffect } from "react";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { setAccessToken } from "@/lib/tokenStore";
|
||||||
|
|
||||||
export function Providers({ children }) {
|
export function Providers({ children }) {
|
||||||
const router = usePathname();
|
useEffect(() => {
|
||||||
|
if (!Cookies.get("userInfo")) return;
|
||||||
|
fetch("/api/auth/refresh", { method: "POST" })
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
if (data?.access_token) setAccessToken(data.access_token);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute={router.includes("/panel") ? "class" : "data-"}
|
attribute="data-"
|
||||||
defaultTheme="system"
|
defaultTheme="system"
|
||||||
enableSystem
|
enableSystem
|
||||||
>
|
>
|
||||||
|
|||||||
+15
-4
@@ -3,14 +3,25 @@ import Layout from "@/components/layout/StLayout";
|
|||||||
import { getStateInfo } from "@/lib/getStateInfo";
|
import { getStateInfo } from "@/lib/getStateInfo";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const { matchedCity } = await getStateInfo();
|
const { matchedCity, matchedState, isRoot } = await getStateInfo();
|
||||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
const siteName = matchedCity?.site_name || "نوبت 724";
|
||||||
const title = `درباره ما | ${siteName}`;
|
const cityName = isRoot ? null : matchedCity?.name;
|
||||||
const description = `آشنایی با ${siteName}، سیستم آنلاین نوبتدهی پزشکی. هدف ما ارائه خدمات سریع و کارآمد رزرو نوبت پزشکی برای همه مردم است.`;
|
const provinceName = isRoot ? null : matchedState?.name;
|
||||||
|
|
||||||
|
// صفحه self-canonical است؛ عنوان و توضیح باید شهری باشند وگرنه ۳۵ دامنه یک
|
||||||
|
// متادیتا اعلام میکنند و همان duplicate با شکل تازه برمیگردد.
|
||||||
|
const title = cityName
|
||||||
|
? `درباره ما | نوبتدهی آنلاین پزشکان ${cityName} | ${siteName}`
|
||||||
|
: `درباره ما | ${siteName}`;
|
||||||
|
const description = cityName
|
||||||
|
? `آشنایی با ${siteName}، سامانه رزرو آنلاین نوبت پزشکان ${cityName}${provinceName ? ` و ${provinceName}` : ""}. جستوجوی پزشک بر اساس تخصص و ثبت نوبت بدون تماس تلفنی.`
|
||||||
|
: `آشنایی با ${siteName}، سیستم آنلاین نوبتدهی پزشکی. هدف ما ارائه خدمات سریع و کارآمد رزرو نوبت پزشکی برای همه مردم است.`;
|
||||||
|
const image = "/assets/images/og-image.png";
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
openGraph: { title, description },
|
openGraph: { title, description, images: [image] },
|
||||||
|
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { axiosInstance } from "@/lib/req";
|
||||||
|
import { clearRefreshCookie, COOKIE_NAME } from "@/lib/refreshCookie";
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
const host = request.headers.get("host");
|
||||||
|
const refreshToken = request.cookies.get(COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
await axiosInstance.post(
|
||||||
|
`${API_URL}/oauth/logout`,
|
||||||
|
{ refresh_token: refreshToken },
|
||||||
|
{ headers: { "Content-Type": "application/json", Authorization: "" } }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// revoke best-effort; clearing the cookie below is what matters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
clearRefreshCookie(response, host);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -1,28 +1,34 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { axiosInstance } from "@/lib/req";
|
import { axiosInstance } from "@/lib/req";
|
||||||
|
import { setRefreshCookie, clearRefreshCookie, COOKIE_NAME } from "@/lib/refreshCookie";
|
||||||
|
|
||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
const { refresh_token } = await request.json();
|
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
const host = request.headers.get("host");
|
||||||
|
const refreshToken = request.cookies.get(COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!refresh_token) {
|
if (!refreshToken) {
|
||||||
return NextResponse.json({ error: "refresh_token is required" }, { status: 400 });
|
return NextResponse.json({ error: "no refresh token" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await axiosInstance.post(
|
const res = await axiosInstance.post(
|
||||||
`${API_URL}/oauth/token/refresh`,
|
`${API_URL}/oauth/token/refresh`,
|
||||||
{ refresh_token },
|
{ refresh_token: refreshToken },
|
||||||
{
|
{ headers: { "Content-Type": "application/json", Authorization: "" } }
|
||||||
headers: { "Content-Type": "application/json", Authorization: "" },
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
return NextResponse.json(res.data, { status: res.status });
|
|
||||||
|
const { access_token, refresh_token, expires_in } = res.data;
|
||||||
|
|
||||||
|
const response = NextResponse.json({ access_token, expires_in }, { status: 200 });
|
||||||
|
setRefreshCookie(response, refresh_token, host);
|
||||||
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response) {
|
const response = NextResponse.json(
|
||||||
return NextResponse.json(error.response.data, { status: error.response.status });
|
error.response?.data ?? { error: "Refresh request failed" },
|
||||||
}
|
{ status: 401 }
|
||||||
return NextResponse.json({ error: "Refresh request failed" }, { status: 500 });
|
);
|
||||||
|
clearRefreshCookie(response, host);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { axiosInstance } from "@/lib/req";
|
import { axiosInstance } from "@/lib/req";
|
||||||
|
import { setRefreshCookie } from "@/lib/refreshCookie";
|
||||||
|
|
||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
const { uuid, code } = await request.json();
|
const { uuid, code } = await request.json();
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
|
||||||
if (!uuid || !code) {
|
if (!uuid || !code) {
|
||||||
@@ -13,20 +13,28 @@ export async function POST(request) {
|
|||||||
const jsonHeaders = { "Content-Type": "application/json", Authorization: "" };
|
const jsonHeaders = { "Content-Type": "application/json", Authorization: "" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// مرحله ۱: تأیید OTP — بدون این، oauth/token کد را verified نمیبیند
|
const verify = await axiosInstance.post(
|
||||||
await axiosInstance.post(
|
|
||||||
`${API_URL}/api/v1/user/verify-code`,
|
`${API_URL}/api/v1/user/verify-code`,
|
||||||
{ uuid, code },
|
{ uuid, code },
|
||||||
{ headers: jsonHeaders }
|
{ headers: jsonHeaders }
|
||||||
);
|
);
|
||||||
|
|
||||||
// مرحله ۲: تبدیل uuid تأییدشده به توکن
|
const grant = verify.data?.data?.grant;
|
||||||
const res = await axiosInstance.post(
|
if (!grant) {
|
||||||
|
return NextResponse.json({ error: "grant not issued" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await axiosInstance.post(
|
||||||
`${API_URL}/oauth/token`,
|
`${API_URL}/oauth/token`,
|
||||||
{ grant_type: "mobile", uuid },
|
{ grant_type: "mobile", grant },
|
||||||
{ headers: jsonHeaders }
|
{ headers: jsonHeaders }
|
||||||
);
|
);
|
||||||
return NextResponse.json(res.data, { status: res.status });
|
|
||||||
|
const { access_token, refresh_token, expires_in } = token.data;
|
||||||
|
|
||||||
|
const response = NextResponse.json({ access_token, expires_in }, { status: 200 });
|
||||||
|
setRefreshCookie(response, refresh_token, request.headers.get("host"));
|
||||||
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
return NextResponse.json(error.response.data, { status: error.response.status });
|
return NextResponse.json(error.response.data, { status: error.response.status });
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { revalidateTag, revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* باطلسازی on-demand کش ISR بلاگ.
|
||||||
|
*
|
||||||
|
* `clinicpro` (App\Blog\Service\BlogCacheInvalidator) بعد از هر
|
||||||
|
* create/update/delete/review این مسیر را صدا میزند تا تغییر پنل ادمین بدون
|
||||||
|
* انتظار یکساعتهٔ `revalidate: 3600` روی سایت بیاید.
|
||||||
|
*
|
||||||
|
* قرارداد: POST { tags: string[] } با هدر `X-Revalidate-Secret`.
|
||||||
|
*/
|
||||||
|
export async function POST(request) {
|
||||||
|
const secret = process.env.REVALIDATE_SECRET;
|
||||||
|
if (!secret || request.headers.get("x-revalidate-secret") !== secret) {
|
||||||
|
return NextResponse.json({ revalidated: false }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const tags = body?.tags;
|
||||||
|
if (!Array.isArray(tags) || tags.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ revalidated: false, error: "tags required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.forEach((tag) => revalidateTag(String(tag)));
|
||||||
|
// فهرست مقالات صفحهٔ خودش را با tag نمیگیرد، پس مسیرش جدا باطل میشود.
|
||||||
|
if (tags.includes("blog-list")) revalidatePath("/blogs");
|
||||||
|
|
||||||
|
return NextResponse.json({ revalidated: true, tags });
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -2,32 +2,29 @@ import AppointmentPage from "@/components/appointment";
|
|||||||
import { getStateInfo } from "@/lib/getStateInfo";
|
import { getStateInfo } from "@/lib/getStateInfo";
|
||||||
import { axiosInstance } from "@/lib/req";
|
import { axiosInstance } from "@/lib/req";
|
||||||
|
|
||||||
async function Appointment({ params }) {
|
export const metadata = {
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
async function Appointment({ params, searchParams }) {
|
||||||
const { doctorId } = await params;
|
const { doctorId } = await params;
|
||||||
|
const { clinic_uuid: clinicUuid } = (await searchParams) ?? {};
|
||||||
const { matchedCity } = await getStateInfo();
|
const { matchedCity } = await getStateInfo();
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
|
||||||
let doctor = null;
|
let doctor = null;
|
||||||
let disabledDates = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doctorRes = await axiosInstance.get(`${API_URL}/api/v1/doctor/${doctorId}`);
|
const doctorRes = await axiosInstance.get(`${API_URL}/api/v1/doctor/${doctorId}`);
|
||||||
doctor = doctorRes.data;
|
doctor = doctorRes.data?.data?.data;
|
||||||
|
|
||||||
if (doctor && doctor.id) {
|
|
||||||
const disabledDatesRes = await axiosInstance.get(
|
|
||||||
`${API_URL}/api/v1/appointment/not-available/${doctor.id}`,
|
|
||||||
{ headers: { "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
disabledDates = disabledDatesRes.data?.data || [];
|
|
||||||
}
|
|
||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppointmentPage
|
<AppointmentPage
|
||||||
doctor={doctor}
|
doctor={doctor}
|
||||||
disabledDates={disabledDates}
|
disabledDates={[]}
|
||||||
matchedCity={matchedCity}
|
matchedCity={matchedCity}
|
||||||
|
initialClinicUuid={typeof clinicUuid === "string" ? clinicUuid : null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+224
-29
@@ -1,71 +1,254 @@
|
|||||||
|
import { cache } from "react";
|
||||||
import BlogPage from "@/components/blog";
|
import BlogPage from "@/components/blog";
|
||||||
import Layout from "@/components/layout/StLayout";
|
import Layout from "@/components/layout/StLayout";
|
||||||
|
import { notFound, permanentRedirect } from "next/navigation";
|
||||||
import { fetchReq } from "@/lib/req";
|
import { fetchReq } from "@/lib/req";
|
||||||
import { getStateInfo } from "@/lib/getStateInfo";
|
import { getStateInfo } from "@/lib/getStateInfo";
|
||||||
|
import { getBlogCanonicalOrigin } from "@/lib/getCanonicalUrl";
|
||||||
|
import {
|
||||||
|
domainScopeCityId,
|
||||||
|
extractEntityCityId,
|
||||||
|
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;
|
||||||
|
|
||||||
|
// scope دامنه در کلید cache مینشیند: یک پست روی دامنهٔ شهریِ دیگر ۴۰۴ است، پس
|
||||||
|
// پاسخِ دامنهٔ ریشه نباید به دامنهٔ شهری نشت کند.
|
||||||
|
const getBlog = cache(async (slug, cityId) => {
|
||||||
|
const query = cityId != null ? `?city_id=${cityId}` : "";
|
||||||
|
const res = await fetch(`${API_URL}/api/v1/blog/${slug}${query}`, {
|
||||||
|
// هر دو tag ثبت میشود چون بکاند با slug و uuid هر دو باطل میکند و URL
|
||||||
|
// سایت uuid است؛ `blog-list` هم برای باطلسازی گروهی همهٔ مقالهها.
|
||||||
|
next: { revalidate: 3600, tags: [`blog-${slug}`, "blog-list"] },
|
||||||
|
});
|
||||||
|
// ۴۰۴ یعنی «روی این دامنه وجود ندارد» — پستِ شهرِ دیگر (یا پستِ دامنهٔ اصلی)
|
||||||
|
// نباید روی دامنهٔ شهری باز شود؛ بکاند همان فیلتر لیست را اینجا هم اعمال میکند.
|
||||||
|
if (res.status === 404 || res.status === 400) return null;
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch blog: ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
return json?.data?.data ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** پست را در scope دامنهٔ جاری میگیرد (دامنهٔ ریشه scope ندارد). */
|
||||||
|
async function getScopedBlog(slug) {
|
||||||
|
const { matchedCity, isRoot } = await getStateInfo();
|
||||||
|
return getBlog(slug, domainScopeCityId({ matchedCity, isRoot }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پست را با هر شناسهای که روزی به آن اشاره داشته پیدا میکند.
|
||||||
|
*
|
||||||
|
* بکاند خودش slug و uuid و topic_slug و پسوند ۸ کاراکتریِ uuid را resolve میکند.
|
||||||
|
* چیزی که آنجا قابل resolve نیست، اسلاگهای لاتینِ نسل قبلاند: هیچ ردی در دیتابیس
|
||||||
|
* ندارند چون هنگام تغییر عنوان بازنویسی شدند. Search Console همانها را روی
|
||||||
|
* `behbahan-nobat.ir` ۴۰۴ گزارش کرد، پس نگاشتشان در data/blogLegacySlugs.json
|
||||||
|
* دستی نگه داشته میشود — مقدارِ نگاشت پیشوند uuid است، نه اسلاگ مقصد، تا فیلتر
|
||||||
|
* شهریِ همان درخواست دوباره اعمال شود و پستِ شهر دیگر ریدایرکتِ ۴۰۴ نسازد.
|
||||||
|
*/
|
||||||
|
async function resolveScopedBlog(slug) {
|
||||||
|
const blog = await getScopedBlog(slug);
|
||||||
|
if (blog) return blog;
|
||||||
|
|
||||||
|
const legacyUuidPrefix = legacySlugs[slug];
|
||||||
|
return legacyUuidPrefix ? getScopedBlog(legacyUuidPrefix) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }) {
|
export async function generateMetadata({ params }) {
|
||||||
const { slug } = await params;
|
const { slug: rawSlug } = await params;
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
const slug = decodeRouteParam(rawSlug);
|
||||||
const { matchedCity } = await getStateInfo();
|
const { matchedCity } = await getStateInfo();
|
||||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
const siteName = matchedCity?.site_name || "نوبت 724";
|
||||||
|
|
||||||
try {
|
const blog = await resolveScopedBlog(slug);
|
||||||
const blog = await fetchReq(`${API_URL}/api/v1/blog/${slug}`);
|
if (!blog) notFound();
|
||||||
if (!blog) return {};
|
|
||||||
|
|
||||||
const title = `${blog.title} | ${siteName}`;
|
try {
|
||||||
const rawText = blog.body ? blog.body.replace(/<[^>]*>/g, "").trim() : "";
|
// برند از شهرِ خودِ پست میآید، نه از دامنهای که اتفاقاً آن را سرو میکند —
|
||||||
const description = rawText.slice(0, 160) || blog.title;
|
// وگرنه پست یاسوجی که روی دامنهٔ یزد باز شود عنوانش «یزد نوبت» میشد در حالی
|
||||||
|
// که canonical آن به یاسوج اشاره میکند. پست سراسری برند دامنهٔ جاری را میگیرد.
|
||||||
|
const blogCity = findCityById(extractEntityCityId(blog));
|
||||||
|
const brand = blogCity?.site_name || siteName;
|
||||||
|
|
||||||
|
// meta_title از فیلد اختصاصی SEO (اگر پایپلاین/ادمین ست کرده باشد) وگرنه عنوان.
|
||||||
|
const title = `${blog.meta_title || blog.title} | ${brand}`;
|
||||||
|
const rawText = (blog.summary || blog.body || "")
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.trim();
|
||||||
|
const baseDescription = rawText.slice(0, 160) || blog.title;
|
||||||
|
// فیلد meta_description اختصاصی مقدم است؛ وگرنه از خلاصه/متن استخراج میشود.
|
||||||
|
const description =
|
||||||
|
blog.meta_description ||
|
||||||
|
(blogCity
|
||||||
|
? `${baseDescription}`.slice(0, 150) + ` | ${blogCity.name}`
|
||||||
|
: baseDescription);
|
||||||
|
// کلیدواژهها از فیلدهای SEO (اصلی + فرعی) + تگها.
|
||||||
|
const keywords = [
|
||||||
|
blog.primary_keyword,
|
||||||
|
...(Array.isArray(blog.secondary_keywords) ? blog.secondary_keywords : []),
|
||||||
|
...(Array.isArray(blog.tags)
|
||||||
|
? blog.tags.map((t) => (typeof t === "string" ? t : t?.name))
|
||||||
|
: []),
|
||||||
|
].filter(Boolean);
|
||||||
|
// ترتیب تقدم og_image → image_url → کاور پیشفرض برند، همان چیزی که صفحه و
|
||||||
|
// کارتها نشان میدهند؛ یک منبع واحد تا OG با تصویر داخل صفحه فرق نکند.
|
||||||
|
const image = blogCover(blog);
|
||||||
|
|
||||||
|
// C1-b — پست شهریافته به دامنهٔ همان شهر canonical میشود؛ پست سراسری (یا پستِ
|
||||||
|
// رکورد ریشهٔ nobat724) به دامنهٔ اصلی، نه دامنهٔ جاری — وگرنه یک محتوا روی چند
|
||||||
|
// دامنه سرو میشد و هرکدام خودش را canonical اعلام میکرد.
|
||||||
|
// فیلد canonical_url اختصاصی (مثلاً نسخهٔ شهریِ پایپلاین) مقدم است.
|
||||||
|
const origin = getBlogCanonicalOrigin(extractEntityCityId(blog));
|
||||||
|
// slug اینجا decode شده است؛ canonical باید شکل درصدی را بدهد وگرنه روی
|
||||||
|
// اسلاگ فارسی یک URL با کاراکتر غیر ASCII منتشر میشود.
|
||||||
|
const canonical = blog.canonical_url || `${origin}/blog/${encodeURIComponent(slug)}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
...(keywords.length && { keywords }),
|
||||||
|
alternates: { canonical },
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
type: "article",
|
type: "article",
|
||||||
...(blog.created && { publishedTime: blog.created }),
|
...(blog.created_at && {
|
||||||
images: blog.images?.[0]
|
publishedTime: new Date(blog.created_at * 1000).toISOString(),
|
||||||
? [blog.images[0]]
|
}),
|
||||||
: ["https://www.nobat724.com/assets/images/logo.png"],
|
...(blog.updated_at && {
|
||||||
|
modifiedTime: new Date(blog.updated_at * 1000).toISOString(),
|
||||||
|
}),
|
||||||
|
images: [image],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
images: blog.images?.[0]
|
images: [image],
|
||||||
? [blog.images[0]]
|
|
||||||
: ["https://www.nobat724.com/assets/images/logo.png"],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error(`[blog/${slug}] generateMetadata failed:`, error);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function Blog({ params }) {
|
async function Blog({ params }) {
|
||||||
const { slug } = await params;
|
const { slug: rawSlug } = await params;
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
const slug = decodeRouteParam(rawSlug);
|
||||||
|
const slugPath = encodeURIComponent(slug);
|
||||||
|
|
||||||
const blog = await fetchReq(`${API_URL}/api/v1/blog/${slug}`);
|
const blog = normalizeBlog(await resolveScopedBlog(slug));
|
||||||
|
if (!blog) notFound();
|
||||||
|
|
||||||
let relatedBlogs = [];
|
// اسلاگ کهنه (عنوان عوض شده یا نسل لاتین) به آدرس قطعی منتقل میشود؛ دو URL برای
|
||||||
if (blog?.tag?.[0]?.id) {
|
// یک محتوا باقی نمیماند و لینکهای قدیمیِ ایندکسشده اعتبارشان را میبرند.
|
||||||
const tagId = blog.tag[0].id;
|
if (blog.slug && blog.slug !== slug) {
|
||||||
const relatedResponse = await fetchReq(
|
permanentRedirect(`/blog/${encodeURIComponent(blog.slug)}`);
|
||||||
`${API_URL}/api/v1/blogs?page=1&limit=12&tag=${tagId}`
|
|
||||||
);
|
|
||||||
relatedBlogs = relatedResponse?.blogs || [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const blogCityId = extractEntityCityId(blog);
|
||||||
|
const blogCity = findCityById(blogCityId);
|
||||||
|
const origin = getBlogCanonicalOrigin(blogCityId);
|
||||||
|
|
||||||
|
// «مقالات مرتبط» هم باید scope دامنه را رعایت کند، وگرنه روی دامنهٔ شهری لینکِ
|
||||||
|
// پستِ شهر دیگر نشان داده میشود که حالا ۴۰۴ میگیرد.
|
||||||
|
const { matchedCity, isRoot } = await getStateInfo();
|
||||||
|
const scopeCityId = domainScopeCityId({ matchedCity, isRoot });
|
||||||
|
const scopeQuery = scopeCityId != null ? `&city_id=${scopeCityId}` : "";
|
||||||
|
|
||||||
|
// دستهٔ مقاله = تگ اول؛ هم بردکرامب و هم مقالات مرتبط از همین میآیند.
|
||||||
|
const categoryName = Array.isArray(blog?.tag) ? blog.tag[0]?.name : null;
|
||||||
|
|
||||||
|
// اول همدستهها؛ دستههای کمپست (مثلاً «قلب و عروق» با یک مقاله) با آخرین
|
||||||
|
// مقالات پر میشوند تا ستون خالی یا کوتاه نماند.
|
||||||
|
const sameTagResponse = categoryName
|
||||||
|
? await fetchReq(
|
||||||
|
`${API_URL}/api/v1/blogs?page=1&limit=6&tag=${encodeURIComponent(categoryName)}${scopeQuery}`
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const latestResponse = await fetchReq(
|
||||||
|
`${API_URL}/api/v1/blogs?page=1&limit=6${scopeQuery}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const seen = new Set([blog?.uuid]);
|
||||||
|
const relatedBlogs = [
|
||||||
|
...(sameTagResponse?.data || []),
|
||||||
|
...(latestResponse?.data || []),
|
||||||
|
]
|
||||||
|
.filter((b) => !seen.has(b.uuid) && seen.add(b.uuid))
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(normalizeBlog);
|
||||||
|
|
||||||
|
const brand = blogCity?.site_name || "نوبت 724";
|
||||||
|
const articleDescription =
|
||||||
|
blog?.meta_description ||
|
||||||
|
(blog?.body?.value || blog?.summary || "").replace(/<[^>]*>/g, "").trim().slice(0, 160);
|
||||||
|
|
||||||
const jsonLd = blog
|
const jsonLd = blog
|
||||||
? {
|
? {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
headline: blog.title,
|
headline: blog.title,
|
||||||
...(blog.images?.[0] && { image: blog.images[0] }),
|
...(articleDescription && { description: articleDescription }),
|
||||||
...(blog.created && { datePublished: blog.created }),
|
image: blogCover(blog),
|
||||||
|
mainEntityOfPage: { "@type": "WebPage", "@id": `${origin}/blog/${slugPath}` },
|
||||||
|
...(blog.created && {
|
||||||
|
datePublished: new Date(blog.created * 1000).toISOString(),
|
||||||
|
}),
|
||||||
|
...(blog.updated_at && {
|
||||||
|
dateModified: new Date(blog.updated_at * 1000).toISOString(),
|
||||||
|
}),
|
||||||
...(blog.author && { author: { "@type": "Person", name: blog.author } }),
|
...(blog.author && { author: { "@type": "Person", name: blog.author } }),
|
||||||
|
publisher: { "@type": "Organization", name: brand },
|
||||||
|
// پست شهریافته حوزهٔ جغرافیاییاش را اعلام میکند؛ پست سراسری این فیلد را ندارد.
|
||||||
|
...(blogCity && {
|
||||||
|
spatialCoverage: { "@type": "Place", name: blogCity.name },
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// FAQPage از فیلد faq (سؤال/جواب) — فقط اگر مقاله FAQ داشته باشد.
|
||||||
|
const faqItems = Array.isArray(blog?.faq)
|
||||||
|
? blog.faq.filter((f) => f?.q && f?.a)
|
||||||
|
: [];
|
||||||
|
const faqJsonLd = faqItems.length
|
||||||
|
? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "FAQPage",
|
||||||
|
mainEntity: faqItems.map((f) => ({
|
||||||
|
"@type": "Question",
|
||||||
|
name: f.q,
|
||||||
|
acceptedAnswer: { "@type": "Answer", text: f.a },
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const breadcrumbJsonLd = blog
|
||||||
|
? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
itemListElement: [
|
||||||
|
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
|
||||||
|
{ "@type": "ListItem", position: 2, name: "مقالات", item: `${origin}/blogs` },
|
||||||
|
// دستهٔ مقاله = همان چیزی که بردکرامب HTML نشان میدهد؛ مقالهٔ بدون تگ
|
||||||
|
// سهسطحی میماند، چون سطح خالی کل BreadcrumbList را نامعتبر میکند.
|
||||||
|
...(categoryName
|
||||||
|
? [{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 3,
|
||||||
|
name: categoryName,
|
||||||
|
item: `${origin}/blogs?tag=${encodeURIComponent(categoryName)}`,
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
// آیتم آخر بدون `item` کل BreadcrumbList را نامعتبر میکند
|
||||||
|
{ "@type": "ListItem", position: categoryName ? 4 : 3, name: blog.title,
|
||||||
|
item: `${origin}/blog/${slugPath}` },
|
||||||
|
],
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -74,7 +257,19 @@ async function Blog({ params }) {
|
|||||||
{jsonLd && (
|
{jsonLd && (
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{breadcrumbJsonLd && (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{faqJsonLd && (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: safeJsonLd(faqJsonLd) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Skeleton } from "@mui/material";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Skeleton variant="rounded" width={150} height={24} className="my-[16px] md:my-[20px] lg:my-[24px]" />
|
||||||
|
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[16px] md:gap-[20px] lg:gap-[24px]">
|
||||||
|
{Array.from({ length: 6 }).map((_, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="rounded-[8px] overflow-hidden bg-[#FFF] shadow-[0px_0px_41px_0px_rgba(173,_181,_189,_0.15)]"
|
||||||
|
>
|
||||||
|
<Skeleton variant="rectangular" width="100%" height={217} />
|
||||||
|
<div className="p-[12px] md:p-[14px] lg:p-[16px]">
|
||||||
|
<Skeleton variant="rounded" width={150} height={18} className="mb-3" />
|
||||||
|
<div className="flex items-center justify-start gap-[16px]">
|
||||||
|
<Skeleton variant="rounded" width={100} height={18} />
|
||||||
|
<Skeleton variant="rounded" width={110} height={18} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+24
-3
@@ -1,3 +1,4 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import BlogsPage from "@/components/blogs";
|
import BlogsPage from "@/components/blogs";
|
||||||
import Layout from "@/components/layout/StLayout";
|
import Layout from "@/components/layout/StLayout";
|
||||||
import { getStateInfo } from "@/lib/getStateInfo";
|
import { getStateInfo } from "@/lib/getStateInfo";
|
||||||
@@ -7,17 +8,37 @@ export async function generateMetadata() {
|
|||||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
const siteName = matchedCity?.site_name || "نوبت 724";
|
||||||
const title = `مقالات و اخبار پزشکی | ${siteName}`;
|
const title = `مقالات و اخبار پزشکی | ${siteName}`;
|
||||||
const description = `جدیدترین مقالات، اخبار و راهنماهای پزشکی. اطلاعات تخصصی در حوزه سلامت و پزشکی از متخصصان ${siteName}.`;
|
const description = `جدیدترین مقالات، اخبار و راهنماهای پزشکی. اطلاعات تخصصی در حوزه سلامت و پزشکی از متخصصان ${siteName}.`;
|
||||||
|
const image = "/assets/images/og-image.png";
|
||||||
|
// کلیدواژههای شهر از city.json (رشته یا آرایه).
|
||||||
|
const rawKeywords = matchedCity?.keywords;
|
||||||
|
const keywords = Array.isArray(rawKeywords)
|
||||||
|
? rawKeywords
|
||||||
|
: typeof rawKeywords === "string" && rawKeywords.trim()
|
||||||
|
? rawKeywords.split(",").map((k) => k.trim()).filter(Boolean)
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
openGraph: { title, description },
|
...(keywords?.length && { keywords }),
|
||||||
|
// C1-a — لیست بلاگ per-domain است (پستهای همان شهر + سراسری)؛
|
||||||
|
// canonical لایهٔ layout (self روی همان دامنه) درست است.
|
||||||
|
openGraph: { title, description, images: [image] },
|
||||||
|
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Blogs() {
|
export default async function Blogs() {
|
||||||
|
const { matchedCity, isRoot } = await getStateInfo();
|
||||||
|
|
||||||
|
// روی دامنهٔ شهری: پستهای همان شهر + سراسری. روی دامنهٔ ریشه: همهٔ پستها.
|
||||||
|
// تشخیص شهر سمت سرور انجام میشود (همان الگوی app/specialties/page.js).
|
||||||
return (
|
return (
|
||||||
<Layout name="/blogs">
|
<Layout name="/blogs">
|
||||||
<BlogsPage />
|
{/* BlogsPage تگ انتخابی را از useSearchParams میخواند؛ Next 15 برای آن
|
||||||
|
مرز Suspense لازم دارد وگرنه prerender صفحه میشکند. */}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<BlogsPage cityId={isRoot ? null : (matchedCity?.id ?? null)} />
|
||||||
|
</Suspense>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+139
-16
@@ -1,58 +1,174 @@
|
|||||||
|
import { cache } from "react";
|
||||||
import ClinicPage from "@/components/clinic";
|
import ClinicPage from "@/components/clinic";
|
||||||
import Layout from "@/components/layout/StLayout";
|
import Layout from "@/components/layout/StLayout";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
import { fetchReq } from "@/lib/req";
|
import { fetchReq } from "@/lib/req";
|
||||||
import { getStateInfo } from "@/lib/getStateInfo";
|
import { getStateInfo } from "@/lib/getStateInfo";
|
||||||
|
import { getEntityOrigin } from "@/lib/getCanonicalUrl";
|
||||||
|
import { extractEntityCityId } from "@/lib/domainHelpers";
|
||||||
|
import { isThinClinic } from "@/lib/entityQuality";
|
||||||
|
import { safeJsonLd } from "@/lib/sanitize";
|
||||||
|
import { imageUrl } from "@/helper";
|
||||||
|
import {
|
||||||
|
getClinicPhone,
|
||||||
|
toE164Ir,
|
||||||
|
getClinicCity,
|
||||||
|
getClinicState,
|
||||||
|
} from "@/lib/clinicContact";
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
|
||||||
|
const getClinic = cache(async (slug) => {
|
||||||
|
if (!slug || slug === "undefined") return null;
|
||||||
|
const res = await fetch(`${API_URL}/api/v1/clinic/${slug}`, {
|
||||||
|
next: { revalidate: 3600, tags: [`clinic-${slug}`] },
|
||||||
|
});
|
||||||
|
if (res.status === 404 || res.status === 400) return null;
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch clinic: ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
return json?.data?.data ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
export async function generateMetadata({ params }) {
|
export async function generateMetadata({ params }) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
|
||||||
const { matchedCity } = await getStateInfo();
|
const { matchedCity } = await getStateInfo();
|
||||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
const siteName = matchedCity?.site_name || "نوبت 724";
|
||||||
|
|
||||||
|
const clinic = await getClinic(slug);
|
||||||
|
if (!clinic) notFound();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const clinic = await fetchReq(`${API_URL}/api/v1/clinic/${slug}`);
|
|
||||||
if (!clinic) return {};
|
|
||||||
|
|
||||||
const title = `${clinic.title} | ${siteName}`;
|
const title = `${clinic.title} | ${siteName}`;
|
||||||
const description = `رزرو نوبت و اطلاعات ${clinic.title}. مشاهده لیست پزشکان و خدمات درمانی موجود.`;
|
const description = `رزرو نوبت و اطلاعات ${clinic.title}. مشاهده لیست پزشکان و خدمات درمانی موجود.`;
|
||||||
|
|
||||||
|
const image = clinic.images_clinic?.[0]?.url
|
||||||
|
? [imageUrl(clinic.images_clinic[0].url)]
|
||||||
|
: ["/assets/images/og-image.png"];
|
||||||
|
|
||||||
|
// C1-b — کلینیک به دامنهٔ شهر خودش canonical میشود.
|
||||||
|
const origin = await getEntityOrigin(extractEntityCityId(clinic));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
alternates: { canonical: `${origin}/clinic/${clinic.uuid}` },
|
||||||
|
// H7 — کلینیک غیرفعال/بیمحتوا نباید ایندکس شود (تقارن با پزشک بیمحتوا)
|
||||||
|
...(isThinClinic(clinic) && { robots: { index: false, follow: true } }),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
images: clinic.images_clinic?.[0]
|
type: "website",
|
||||||
? [clinic.images_clinic[0]]
|
images: image,
|
||||||
: ["https://www.nobat724.com/assets/images/logo.png"],
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images: image,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error(`[clinic/${slug}] generateMetadata failed:`, error);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeClinicRating(doctors) {
|
||||||
|
const rated = doctors.filter((d) => d.point && Number(d.point) > 0);
|
||||||
|
if (rated.length === 0) return null;
|
||||||
|
const avg = rated.reduce((sum, d) => sum + Number(d.point), 0) / rated.length;
|
||||||
|
return {
|
||||||
|
"@type": "AggregateRating",
|
||||||
|
ratingValue: avg.toFixed(1),
|
||||||
|
ratingCount: rated.length,
|
||||||
|
bestRating: "5",
|
||||||
|
worstRating: "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function Clinic({ params, searchParams }) {
|
async function Clinic({ params, searchParams }) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
const sp = await searchParams;
|
||||||
const page = Number(searchParams?.page) || 1;
|
const page = Number(sp?.page) || 1;
|
||||||
const limit = 50;
|
const limit = 50;
|
||||||
|
|
||||||
const reqClinics = await fetchReq(`${API_URL}/api/v1/clinic/${slug}`);
|
const clinic = await getClinic(slug);
|
||||||
|
if (!clinic) notFound();
|
||||||
|
|
||||||
|
// همان مقصد canonical — تا url/@id و breadcrumb سیگنال متناقض ندهند.
|
||||||
|
const origin = await getEntityOrigin(extractEntityCityId(clinic));
|
||||||
const reqDoctors = await fetchReq(
|
const reqDoctors = await fetchReq(
|
||||||
`${API_URL}/api/v1/clinic/doctor-list/${slug}?page=${page}&limit=${limit}`
|
`${API_URL}/api/v1/clinic/doctor-list/${slug}?page=${page}&limit=${limit}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const clinic = reqClinics;
|
const doctors = reqDoctors?.data?.data || [];
|
||||||
const doctors = reqDoctors?.data || [];
|
const meta = reqDoctors?.data?.meta;
|
||||||
const pagedoctors = reqDoctors?.page;
|
const pagedoctors = meta
|
||||||
|
? { current: meta.currentPage, total_pages: meta.totalPages }
|
||||||
|
: { current: 1, total_pages: 1 };
|
||||||
|
|
||||||
|
const aggregateRating = computeClinicRating(doctors);
|
||||||
|
|
||||||
|
const specialties = [
|
||||||
|
...new Set(
|
||||||
|
doctors.flatMap((d) => d.specialties?.map((s) => s.name) ?? [])
|
||||||
|
),
|
||||||
|
].slice(0, 5);
|
||||||
|
|
||||||
|
const sameAsLinks = clinic?.social_media
|
||||||
|
? Object.values(clinic.social_media).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const clinicPhone = getClinicPhone(clinic);
|
||||||
|
const clinicCity = getClinicCity(clinic);
|
||||||
|
const clinicState = getClinicState(clinic);
|
||||||
|
|
||||||
const jsonLd = clinic
|
const jsonLd = clinic
|
||||||
? {
|
? {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "MedicalClinic",
|
"@type": "MedicalClinic",
|
||||||
name: clinic.title,
|
name: clinic.title,
|
||||||
...(clinic.images_clinic?.[0] && { image: clinic.images_clinic[0] }),
|
url: `${origin}/clinic/${clinic.uuid}`,
|
||||||
|
...(clinic.images_clinic?.[0]?.url && {
|
||||||
|
image: {
|
||||||
|
"@type": "ImageObject",
|
||||||
|
url: imageUrl(clinic.images_clinic[0].url),
|
||||||
|
name: clinic.title,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(clinicPhone && { telephone: toE164Ir(clinicPhone) }),
|
||||||
|
...((clinic.location || clinicCity) && {
|
||||||
|
address: {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
...(clinic.location && { streetAddress: clinic.location.trim() }),
|
||||||
|
...(clinicCity && { addressLocality: clinicCity }),
|
||||||
|
...(clinicState && { addressRegion: clinicState }),
|
||||||
|
addressCountry: "IR",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(clinic.map?.latitude && clinic.map?.longitude && {
|
||||||
|
geo: {
|
||||||
|
"@type": "GeoCoordinates",
|
||||||
|
latitude: clinic.map.latitude,
|
||||||
|
longitude: clinic.map.longitude,
|
||||||
|
},
|
||||||
|
hasMap: `https://maps.google.com/?q=${clinic.map.latitude},${clinic.map.longitude}`,
|
||||||
|
}),
|
||||||
|
...(clinic["24_7"] && {
|
||||||
|
openingHoursSpecification: {
|
||||||
|
"@type": "OpeningHoursSpecification",
|
||||||
|
dayOfWeek: [
|
||||||
|
"Monday", "Tuesday", "Wednesday", "Thursday",
|
||||||
|
"Friday", "Saturday", "Sunday",
|
||||||
|
],
|
||||||
|
opens: "00:00",
|
||||||
|
closes: "23:59",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(aggregateRating && { aggregateRating }),
|
||||||
|
...(specialties.length > 0 && { medicalSpecialty: specialties }),
|
||||||
|
...(sameAsLinks.length > 0 && { sameAs: sameAsLinks }),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -61,11 +177,18 @@ async function Clinic({ params, searchParams }) {
|
|||||||
{jsonLd && (
|
{jsonLd && (
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* BreadcrumbList از خودِ کامپوننت Pageguide میآید — یک منبع داده برای بصری و schema */}
|
||||||
<Layout>
|
<Layout>
|
||||||
<ClinicPage data={clinic} doctors={doctors} slug={slug} pages={pagedoctors} />
|
<ClinicPage
|
||||||
|
data={clinic}
|
||||||
|
doctors={doctors}
|
||||||
|
slug={slug}
|
||||||
|
pages={pagedoctors}
|
||||||
|
origin={origin}
|
||||||
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import ItemClinic from "@/components/clinics/list/ItemClinic";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="padding-responsive pt-[20px]">
|
||||||
|
<ul
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-[24px] gap-y-[16px] sm:gap-y-[24px] md:gap-y-[32px] lg:gap-y-[40px] mt-6"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 6 }).map((_, idx) => (
|
||||||
|
<ItemClinic key={idx} data={{}} loading setFilteredClinics={() => {}} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+42
-12
@@ -1,29 +1,39 @@
|
|||||||
import { fetchReq } from "@/lib/req";
|
import { fetchReq } from "@/lib/req";
|
||||||
|
import { isNextRedirectError } from "@/lib/maintenance";
|
||||||
import ClinicsPage from "@/components/clinics";
|
import ClinicsPage from "@/components/clinics";
|
||||||
import Layout from "@/components/layout/StLayout";
|
import Layout from "@/components/layout/StLayout";
|
||||||
import { getStateInfo } from "@/lib/getStateInfo";
|
import { getStateInfo } from "@/lib/getStateInfo";
|
||||||
import { buildClinicParams } from "@/helper";
|
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() {
|
export async function generateMetadata({ searchParams }) {
|
||||||
const { matchedCity, matchedState } = await getStateInfo();
|
const awaitedParams = await searchParams;
|
||||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
const { matchedCity, matchedState, isRoot, repContext } = await getStateInfo();
|
||||||
const cityName = matchedCity?.name || matchedState?.name || "";
|
const siteName = matchedCity?.site_name || repContext?.full_name || "نوبت 724";
|
||||||
|
// روی دامنهٔ ریشه نام رکورد («نوبت 724») برند است نه شهر — نباید در Title بنشیند.
|
||||||
|
const cityName = isRoot ? "" : matchedCity?.name || matchedState?.name || "";
|
||||||
const title = cityName
|
const title = cityName
|
||||||
? `کلینیکهای ${cityName} | جستجو و رزرو نوبت | ${siteName}`
|
? `کلینیکهای ${cityName} | جستجو و رزرو نوبت | ${siteName}`
|
||||||
: `جستجوی کلینیک و مراکز درمانی | ${siteName}`;
|
: `جستجوی کلینیک و مراکز درمانی | ${siteName}`;
|
||||||
const description = cityName
|
const description = cityName
|
||||||
? `لیست کلینیکها و مراکز درمانی در ${cityName}. رزرو آنلاین نوبت از بهترین مراکز درمانی ${cityName}.`
|
? `لیست کلینیکها و مراکز درمانی در ${cityName}. رزرو آنلاین نوبت از بهترین مراکز درمانی ${cityName}.`
|
||||||
: `جستجوی کلینیکها و مراکز درمانی در سراسر کشور. رزرو آنلاین نوبت سریع و آسان.`;
|
: `جستجوی کلینیکها و مراکز درمانی در سراسر کشور. رزرو آنلاین نوبت سریع و آسان.`;
|
||||||
|
const image = "/assets/images/og-image.png";
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
openGraph: { title, description },
|
...listingRobots(awaitedParams),
|
||||||
|
openGraph: { title, description, images: [image] },
|
||||||
|
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Clinics({ searchParams }) {
|
export default async function Clinics({ searchParams }) {
|
||||||
const awaitedSearchParams = await searchParams;
|
const awaitedSearchParams = await searchParams;
|
||||||
const { matchedCity, matchedState } = await getStateInfo();
|
const { matchedCity, matchedState, isRoot, repContext, host } = await getStateInfo();
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
const stateParams = awaitedSearchParams.state;
|
const stateParams = awaitedSearchParams.state;
|
||||||
const cityParams = awaitedSearchParams.city;
|
const cityParams = awaitedSearchParams.city;
|
||||||
@@ -31,24 +41,38 @@ export default async function Clinics({ searchParams }) {
|
|||||||
let clinics;
|
let clinics;
|
||||||
try {
|
try {
|
||||||
// Params
|
// Params
|
||||||
let newSearchParams = awaitedSearchParams;
|
// کپی — awaitedSearchParams همان شیئی است که generateMetadata هم میخواند؛
|
||||||
|
// تزریق city/state روی خودش، سیاست robots را به noindex میبرد.
|
||||||
|
const newSearchParams = { ...awaitedSearchParams };
|
||||||
|
|
||||||
// Conditional State
|
// Conditional State — روی دامنهٔ ریشه (nobat724) فیلتر اعمال نمیشود.
|
||||||
if (stateParams) newSearchParams.state = stateParams;
|
if (stateParams) newSearchParams.state = stateParams;
|
||||||
else if (matchedState) newSearchParams.state = matchedState.name;
|
else if (matchedState && !isRoot) newSearchParams.state = matchedState.name;
|
||||||
|
|
||||||
// Conditional City
|
// Conditional City
|
||||||
if (cityParams) newSearchParams.city = cityParams;
|
if (cityParams) newSearchParams.city = cityParams;
|
||||||
else if (matchedCity && matchedCity.id !== "600")
|
else if (matchedCity && !isRoot) newSearchParams.city = matchedCity.name;
|
||||||
newSearchParams.city = matchedCity.name;
|
|
||||||
|
|
||||||
const params = buildClinicParams(newSearchParams);
|
const params = buildClinicParams(newSearchParams);
|
||||||
|
|
||||||
|
// دامنهی نماینده سراسری: backend فقط کلینیکهای همان نماینده را برمیگرداند؛ فیلتر شهر بیمعنی است.
|
||||||
|
if (repContext?.is_global) {
|
||||||
|
delete params.city;
|
||||||
|
delete params.state;
|
||||||
|
params.domain = host;
|
||||||
|
}
|
||||||
|
|
||||||
// Req
|
// Req
|
||||||
clinics = await fetchReq(`${API_URL}/api/v1/clinics`, {
|
clinics = await fetchReq(`${API_URL}/api/v1/clinics`, {
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
} catch (err) { }
|
} catch (err) {
|
||||||
|
// ریدایرکت نکست با throw کار میکند؛ بدون این rethrow، ریدایرکت حالت تعمیرات بلعیده میشود.
|
||||||
|
if (isNextRedirectError(err)) throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// origin برای BreadcrumbList لازم است: گوگل در itemListElement آدرس مطلق میخواهد.
|
||||||
|
const origin = await getRequestOrigin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout name="/clinics">
|
<Layout name="/clinics">
|
||||||
@@ -56,6 +80,12 @@ export default async function Clinics({ searchParams }) {
|
|||||||
clinics={clinics}
|
clinics={clinics}
|
||||||
matchedCity={matchedCity}
|
matchedCity={matchedCity}
|
||||||
matchedState={matchedState}
|
matchedState={matchedState}
|
||||||
|
origin={origin}
|
||||||
|
// قرینهٔ /doctors — buildClinicsIntro هم نوشته و تست شده بود ولی رندر نمیشد.
|
||||||
|
intro={buildClinicsIntro(
|
||||||
|
resolveCityDisplayName(isRoot ? null : matchedCity),
|
||||||
|
clinics?.meta?.totalRecords
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ function CustomTable({ tableHead, zeroRadius, centerTable, children }) {
|
|||||||
{tableHead.map((item, idx) => (
|
{tableHead.map((item, idx) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={idx}
|
key={idx}
|
||||||
className={`
|
className={` !text-[#616161] dark:!border-none dark:!text-[#D7D8ED] !text-[14px] !font-normal !py-[10px] !px-[18px] ${centerTable.includes(idx) ? "!text-center" : "!text-start"} `}
|
||||||
!text-[#616161] dark:!border-none dark:!text-[#D7D8ED] !text-[14px] !font-normal !py-[10px] !px-[18px]
|
|
||||||
${centerTable.includes(idx) ? "!text-center" : "!text-start"}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{item}
|
{item}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -1,34 +1,18 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import ProfilePA from "@/components/icons/ProfilePA";
|
import ProfilePA from "@/components/icons/ProfilePA";
|
||||||
import LogoutP from "@/components/icons/LogoutP";
|
import LogoutP from "@/components/icons/LogoutP";
|
||||||
import CardDA from "@/components/icons/CardDA";
|
import CardDA from "@/components/icons/CardDA";
|
||||||
import { Button, Popover } from "@mui/material";
|
import { Button, Popover } from "@mui/material";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import ModalLogout from "./ModalLogout";
|
import ModalLogout from "./ModalLogout";
|
||||||
import Cookies from "js-cookie";
|
|
||||||
|
|
||||||
function DetailProfile({ anchorEl, setAnchorEl }) {
|
function DetailProfile({ anchorEl, setAnchorEl }) {
|
||||||
const [modal, setModal] = useState(false);
|
const [modal, setModal] = useState(false);
|
||||||
const [hasRepresentationRole, setHasRepresentationRole] = useState(false);
|
|
||||||
|
|
||||||
const handleClose = () => setAnchorEl(null);
|
const handleClose = () => setAnchorEl(null);
|
||||||
const closeModal = () => setModal(false);
|
const closeModal = () => setModal(false);
|
||||||
const openModal = () => setModal(true);
|
const openModal = () => setModal(true);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const userInfo = Cookies.get("userInfo");
|
|
||||||
if (userInfo) {
|
|
||||||
try {
|
|
||||||
const parsedUserInfo = JSON.parse(userInfo);
|
|
||||||
const roles = parsedUserInfo?.roles || {};
|
|
||||||
const hasRole = Object.values(roles).includes("representation");
|
|
||||||
setHasRepresentationRole(hasRole);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error parsing userInfo:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const open = Boolean(anchorEl);
|
const open = Boolean(anchorEl);
|
||||||
const id = open ? "simple-popover" : undefined;
|
const id = open ? "simple-popover" : undefined;
|
||||||
|
|
||||||
@@ -74,18 +58,6 @@ function DetailProfile({ anchorEl, setAnchorEl }) {
|
|||||||
تراکنش های من
|
تراکنش های من
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
{hasRepresentationRole && (
|
|
||||||
<Link href="/panel/dashboard" className="w-full">
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="text"
|
|
||||||
className="!text-[#7E7E7E] !text-[14px] !font-medium !flex !justify-start !p-[8px] !gap-[5px]"
|
|
||||||
>
|
|
||||||
<ProfilePA />
|
|
||||||
داشبورد نماینده
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
color="error"
|
color="error"
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { imageUrl } from "@/helper";
|
||||||
|
|
||||||
|
function getInitials(name) {
|
||||||
|
if (!name) return "";
|
||||||
|
const cleanName = name.trim().replace(/^دکتر\s+/, "");
|
||||||
|
const parts = cleanName.split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) return "";
|
||||||
|
if (parts.length === 1) return parts[0][0];
|
||||||
|
return `${parts[0][0]} - ${parts[1][0]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DoctorAvatar({ doctor, size = 72, className = "", priority = false }) {
|
||||||
|
const url = doctor?.img?.[0]?.url;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
className={`flex items-center justify-center rounded-full bg-[#F8F8FF] text-[#3B3B3B] font-bold select-none ${className}`}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: size * 0.22 }} className="whitespace-nowrap">
|
||||||
|
{getInitials(doctor?.name)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
alt="image-doctor"
|
||||||
|
src={imageUrl(url)}
|
||||||
|
className={`object-contain rounded-full ${className}`}
|
||||||
|
priority={priority}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DoctorAvatar;
|
||||||
+42
-40
@@ -1,11 +1,11 @@
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import Image from "next/image";
|
|
||||||
import { Button } from "@mui/material";
|
import { Button } from "@mui/material";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import CustomLoading from "./loading/Custom";
|
import CustomLoading from "./loading/Custom";
|
||||||
import TextLoading from "./loading/Text";
|
import TextLoading from "./loading/Text";
|
||||||
import CircularLoading from "./loading/Circular";
|
import CircularLoading from "./loading/Circular";
|
||||||
import { imageUrl } from "@/helper";
|
import DoctorAvatar from "./DoctorAvatar";
|
||||||
|
import SpecialtyChips from "./SpecialtyChips";
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
import ArrowLeftD from "@/components/icons/ArrowLeftD";
|
import ArrowLeftD from "@/components/icons/ArrowLeftD";
|
||||||
@@ -14,6 +14,12 @@ import LikeD from "@/components/icons/LikeD";
|
|||||||
import PointD from "@/components/icons/PointD";
|
import PointD from "@/components/icons/PointD";
|
||||||
|
|
||||||
function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
|
function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
|
||||||
|
// بدون uuid لینکی وجود ندارد. اسکلتِ بارگذاری آبجکتِ بیuuid میدهد و پیش از این
|
||||||
|
// شش `<a href="/doctor/undefined">` در HTML مینشست؛ Googlebot همان را crawl میکرد
|
||||||
|
// و ۴۰۴ میگرفت (گزارش Search Console، ۳۰ ژوئیه).
|
||||||
|
const href = doctor?.uuid ? `/doctor/${doctor.uuid}` : null;
|
||||||
|
const label = doctor?.display_name || doctor?.name;
|
||||||
|
|
||||||
const handleLoading = () => {
|
const handleLoading = () => {
|
||||||
setDoctors((prevDoctors) =>
|
setDoctors((prevDoctors) =>
|
||||||
prevDoctors.map((item) =>
|
prevDoctors.map((item) =>
|
||||||
@@ -29,31 +35,25 @@ function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
|
|||||||
{/* Detail Doctor */}
|
{/* Detail Doctor */}
|
||||||
<div className="flex items-center justify-start gap-2.5">
|
<div className="flex items-center justify-start gap-2.5">
|
||||||
<CircularLoading loading={loading} width={66} height={66}>
|
<CircularLoading loading={loading} width={66} height={66}>
|
||||||
<Image
|
<DoctorAvatar
|
||||||
width={72}
|
doctor={doctor}
|
||||||
height={72}
|
size={72}
|
||||||
alt="image-doctor"
|
className="w-[64px] md:w-[68px] lg:w-[72px] h-[64px] md:h-[68px] lg:h-[72px]"
|
||||||
src={imageUrl(doctor?.img?.[0]?.url)}
|
|
||||||
className="object-contain rounded-full w-[64px] md:w-[68px] lg:w-[72px] h-[64px] md:h-[68px] lg:h-[72px]"
|
|
||||||
priority={priority}
|
priority={priority}
|
||||||
/>
|
/>
|
||||||
</CircularLoading>
|
</CircularLoading>
|
||||||
<div className="flex flex-col items-start justify-center gap-2">
|
<div className="flex flex-col items-start justify-center gap-2">
|
||||||
<TextLoading loading={loading} width={90} height={15}>
|
<TextLoading loading={loading} width={90} height={15}>
|
||||||
<Link href={`/doctor/${doctor?.uuid}`}>
|
{href ? (
|
||||||
<p className="text-[#3B3B3B] text-[14px] font-bold">
|
<Link href={href}>
|
||||||
{doctor?.name}
|
<p className="text-[#3B3B3B] text-[14px] font-bold">{label}</p>
|
||||||
</p>
|
</Link>
|
||||||
</Link>
|
) : (
|
||||||
|
<p className="text-[#3B3B3B] text-[14px] font-bold">{label}</p>
|
||||||
|
)}
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
<TextLoading loading={loading} width={120} height={15}>
|
<TextLoading loading={loading} width={120} height={15}>
|
||||||
<p className="text-[#616161] text-[14px] font-normal">
|
<SpecialtyChips specialties={doctor?.specialties} />
|
||||||
تخصص:
|
|
||||||
{doctor?.specialties?.map(
|
|
||||||
(item, idx) =>
|
|
||||||
`${item.name} ${doctor.specialties.length === idx + 1 ? "" : "|"} `
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
<CustomLoading loading={loading} width={100} height={25}>
|
<CustomLoading loading={loading} width={100} height={25}>
|
||||||
<div className="flex rounded items-center justify-start gap-2">
|
<div className="flex rounded items-center justify-start gap-2">
|
||||||
@@ -78,35 +78,37 @@ function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
|
|||||||
<div className="flex my-4 items-center justify-start gap-0.5">
|
<div className="flex my-4 items-center justify-start gap-0.5">
|
||||||
<ClockD />
|
<ClockD />
|
||||||
<TextLoading loading={loading} width={150} height={15}>
|
<TextLoading loading={loading} width={150} height={15}>
|
||||||
<p className="text-[#7E7E7E] text-[12px] font-normal">
|
<p className="text-[#616161] text-[12px] font-normal">
|
||||||
ساعت کاری: {doctor?.hours_of_work}
|
ساعت کاری: {doctor?.hours_of_work}
|
||||||
</p>
|
</p>
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
</div>
|
</div>
|
||||||
<CustomLoading loading={loading} width={150} height={20}>
|
<CustomLoading loading={loading} width={150} height={20}>
|
||||||
<p
|
<p
|
||||||
className={`text-[12px] font-medium rounded w-fit p-1.5 ml-[52px]
|
className={`text-[12px] font-medium rounded w-fit p-1.5 ml-[52px] ${Number(doctor?.active) ? "bg-[rgba(5,_186,_88,_0.04)] text-[#05BA58]" : "bg-[rgba(211,_47,_47,_0.04)] text-[#D32F2F]" }`}
|
||||||
${Number(doctor?.active)
|
|
||||||
? "bg-[rgba(5,_186,_88,_0.04)] text-[#05BA58]"
|
|
||||||
: "bg-[rgba(211,_47,_47,_0.04)] text-[#D32F2F]"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{doctor?.free_turn}
|
{Number(doctor?.active) && doctor?.free_turn
|
||||||
|
? `اولین نوبت آزاد: ${doctor.free_turn}`
|
||||||
|
: "نوبتدهی غیرفعال است"}
|
||||||
</p>
|
</p>
|
||||||
</CustomLoading>
|
</CustomLoading>
|
||||||
{/* Arrow */}
|
{/* Arrow — روی کارتِ بدون uuid (اسکلت بارگذاری) اصلاً رندر نمیشود؛ دکمهای که
|
||||||
<Link href={`/doctor/${doctor?.uuid}`}>
|
به جایی نمیرود، هم بیمعناست هم لینکِ شکسته در HTML میگذارد. */}
|
||||||
<Button
|
{href && (
|
||||||
variant="contained"
|
<Link href={href} aria-label={`مشاهده پروفایل ${label || "پزشک"}`}>
|
||||||
onClick={handleLoading}
|
<Button
|
||||||
disabled={doctor?.loading}
|
variant="contained"
|
||||||
className="!p-[14px] !absolute !left-4 !bottom-4 !rounded-full !w-fit"
|
onClick={handleLoading}
|
||||||
>
|
disabled={doctor?.loading}
|
||||||
<div className={doctor && doctor.loading ? "opacity-0" : ""}>
|
aria-label={`مشاهده پروفایل ${label || "پزشک"}`}
|
||||||
<ArrowLeftD />
|
className="!p-[14px] !absolute !left-4 !bottom-4 !rounded-full !w-fit"
|
||||||
</div>
|
>
|
||||||
</Button>
|
<div className={doctor && doctor.loading ? "opacity-0" : ""}>
|
||||||
</Link>
|
<ArrowLeftD />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import ItemDoctor from "@/app/component/ItemDoctor";
|
||||||
|
import Loading from "@/app/doctors/loading";
|
||||||
|
|
||||||
|
const DOCTOR = {
|
||||||
|
id: 1,
|
||||||
|
uuid: "84b7ea9e-2fe1-4413-906d-7a5bf05e6f8f",
|
||||||
|
name: "محمدباقر جهانتاب",
|
||||||
|
specialties: [{ id: "13", name: "جراحی عمومی", parent_id: null }],
|
||||||
|
active: 1,
|
||||||
|
free_turn: "شنبه ۱۷:۴۵",
|
||||||
|
};
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
describe("ItemDoctor — لینک پروفایل", () => {
|
||||||
|
it("پزشک با uuid لینک میگیرد", () => {
|
||||||
|
render(<ItemDoctor doctor={DOCTOR} setDoctors={noop} />);
|
||||||
|
|
||||||
|
const links = screen.getAllByRole("link");
|
||||||
|
expect(links.length).toBeGreaterThan(0);
|
||||||
|
for (const link of links) {
|
||||||
|
expect(link).toHaveAttribute("href", `/doctor/${DOCTOR.uuid}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("کارت بدون uuid هیچ لینکی ندارد", () => {
|
||||||
|
render(<ItemDoctor doctor={{ id: 0 }} loading setDoctors={noop} />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("رشتهٔ undefined هرگز در HTML نمینشیند", () => {
|
||||||
|
// ریشهٔ خطای Not found (404) در Search Console: href="/doctor/undefined".
|
||||||
|
const { container } = render(
|
||||||
|
<ItemDoctor doctor={{ id: 0 }} loading setDoctors={noop} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.innerHTML).not.toContain("undefined");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("نام پزشک حتی بدون لینک نمایش داده میشود", () => {
|
||||||
|
render(<ItemDoctor doctor={{ id: 0, name: "بدون شناسه" }} setDoctors={noop} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("بدون شناسه")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("display_name بر name مقدم است", () => {
|
||||||
|
render(
|
||||||
|
<ItemDoctor
|
||||||
|
doctor={{ ...DOCTOR, display_name: "دکتر جهانتاب" }}
|
||||||
|
setDoctors={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("دکتر جهانتاب")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("اسکلت بارگذاری /doctors", () => {
|
||||||
|
it("هیچ لینک doctor/undefined تولید نمیکند", () => {
|
||||||
|
const { container } = render(<Loading />);
|
||||||
|
|
||||||
|
expect(container.innerHTML).not.toContain("/doctor/undefined");
|
||||||
|
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("همچنان شش کارت اسکلت میسازد", () => {
|
||||||
|
const { container } = render(<Loading />);
|
||||||
|
|
||||||
|
expect(container.querySelectorAll("li")).toHaveLength(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
+13
-4
@@ -3,18 +3,27 @@ import Link from "next/link";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
function Logo({ isAbsolute, matchedCity }) {
|
function Logo({ isAbsolute, matchedCity }) {
|
||||||
|
const siteName =
|
||||||
|
matchedCity?.site_name ||
|
||||||
|
(matchedCity?.name ? `${matchedCity.name} نوبت` : "نوبت ۷۲۴");
|
||||||
return (
|
return (
|
||||||
<Link href="/" className={isAbsolute ? "absolute right-[32px] top-0" : ""}>
|
<Link
|
||||||
|
href="/"
|
||||||
|
aria-label={isAbsolute ? undefined : siteName}
|
||||||
|
aria-hidden={isAbsolute ? true : undefined}
|
||||||
|
tabIndex={isAbsolute ? -1 : undefined}
|
||||||
|
className={isAbsolute ? "absolute right-[32px] top-0" : ""}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-start gap-[5px]">
|
<div className="flex items-center justify-start gap-[5px]">
|
||||||
<Image
|
<Image
|
||||||
className="w-[20px] h-[20px] sm:w-[23px] sm:h-[23px] md:w-[26px] md:h-[26px] lg:w-[30px] lg:h-[30px]"
|
className="w-[20px] h-[20px] sm:w-[23px] sm:h-[23px] md:w-[26px] md:h-[26px] lg:w-[30px] lg:h-[30px]"
|
||||||
src="/assets/images/logo.png"
|
src="/nobat724.svg"
|
||||||
width={30}
|
width={30}
|
||||||
height={30}
|
height={30}
|
||||||
alt="logo"
|
alt=""
|
||||||
/>
|
/>
|
||||||
<p className="text-[#526CAC] text-[14px] lg:text-[16px] font-bold">
|
<p className="text-[#526CAC] text-[14px] lg:text-[16px] font-bold">
|
||||||
{matchedCity?.site_name || "نوبت ۷۲۴"}
|
{siteName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ import { useState } from "react";
|
|||||||
function ModalLogout({ open, handleClose }) {
|
function ModalLogout({ open, handleClose }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const logout = () => {
|
const logout = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
} catch {
|
||||||
|
// clearing client state below is what matters
|
||||||
|
}
|
||||||
removeToken();
|
removeToken();
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
};
|
};
|
||||||
|
|||||||
+58
-26
@@ -1,30 +1,62 @@
|
|||||||
function Pageguide({ list }) {
|
import Link from "next/link";
|
||||||
|
import { safeJsonLd } from "@/lib/sanitize";
|
||||||
|
import { buildBreadcrumbJsonLd, normalizeBreadcrumb } from "@/lib/breadcrumb";
|
||||||
|
|
||||||
|
// Breadcrumb واحد سایت: ظاهر دقیقاً همان نسخهٔ قبلی (همان کلاسها، همان جداکنندهٔ «>»)
|
||||||
|
// ولی ساختار سمانتیک درست (nav/ol/a) و BreadcrumbList از همان یک منبع داده تولید میشود
|
||||||
|
// تا نسخهٔ بصری و schema هرگز واگرا نشوند.
|
||||||
|
//
|
||||||
|
// list: Array<string | { name, href }> — به آیتم آخر (صفحهٔ جاری) هم href بده؛
|
||||||
|
// ظاهرش عوض نمیشود (هرگز لینک نمیشود) ولی schema بدون `item` نامعتبر است.
|
||||||
|
// `origin` هم لازم است، چون گوگل URL مطلق میخواهد؛ بدون آن JSON-LD منتشر نمیشود.
|
||||||
|
|
||||||
|
const ITEM_CLASS = "text-[16px] font-normal";
|
||||||
|
|
||||||
|
function Pageguide({ list = [], origin }) {
|
||||||
|
const items = normalizeBreadcrumb(list);
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
const jsonLd = buildBreadcrumbJsonLd(items, origin);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="flex items-center justify-start gap-1">
|
<>
|
||||||
{list.map((item, idx) => (
|
{jsonLd && (
|
||||||
<li key={idx} className="flex items-center justify-start gap-1">
|
<script
|
||||||
<h2
|
type="application/ld+json"
|
||||||
className={`text-[16px] font-normal
|
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
|
||||||
${
|
/>
|
||||||
list.length > idx + 1
|
)}
|
||||||
? "text-[#9B9B9B]"
|
<nav aria-label="breadcrumb">
|
||||||
: "text-[#525252]"
|
<ol className="flex items-center justify-start gap-1">
|
||||||
}
|
{items.map((item, idx) => {
|
||||||
`}
|
const isLast = idx === items.length - 1;
|
||||||
>
|
const colorClass = isLast ? "text-[#525252]" : "text-[#9B9B9B]";
|
||||||
{item}
|
return (
|
||||||
</h2>
|
<li key={idx} className="flex items-center justify-start gap-1">
|
||||||
<span
|
{item.href && !isLast ? (
|
||||||
className={`
|
<Link href={item.href} className={`${ITEM_CLASS} ${colorClass}`}>
|
||||||
text-[#9B9B9B] text-[16px] font-normal
|
{item.name}
|
||||||
${list.length > idx + 1 ? "flex" : "hidden"}
|
</Link>
|
||||||
`}
|
) : (
|
||||||
>
|
<span
|
||||||
{">"}
|
className={`${ITEM_CLASS} ${colorClass}`}
|
||||||
</span>
|
{...(isLast && { "aria-current": "page" })}
|
||||||
</li>
|
>
|
||||||
))}
|
{item.name}
|
||||||
</ul>
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`text-[#9B9B9B] ${ITEM_CLASS} ${isLast ? "hidden" : "flex"}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{">"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { Pagination, useMediaQuery, useTheme } from "@mui/material";
|
"use client";
|
||||||
|
|
||||||
|
import { Pagination, PaginationItem, useMediaQuery, useTheme } from "@mui/material";
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
function PaginationContent({ page, pageDetail, changePage, limit = 12 }) {
|
function PaginationContent({ page, pageDetail, changePage, limit = 12 }) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("sm"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const count = Math.ceil(pageDetail.totalRecords / limit);
|
const count = Math.ceil(pageDetail.totalRecords / limit);
|
||||||
|
|
||||||
|
// href واقعی برای خزندهها؛ کلیک کاربر همچنان SPA میماند (preventDefault + onChange)
|
||||||
|
const buildHref = (pageNum) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set("page", String(pageNum));
|
||||||
|
return `${pathname}?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pagination
|
<Pagination
|
||||||
count={count}
|
count={count}
|
||||||
@@ -14,6 +26,23 @@ function PaginationContent({ page, pageDetail, changePage, limit = 12 }) {
|
|||||||
onChange={changePage}
|
onChange={changePage}
|
||||||
color="primary"
|
color="primary"
|
||||||
className={(!count || count < 2) && "!hidden"}
|
className={(!count || count < 2) && "!hidden"}
|
||||||
|
renderItem={(item) => {
|
||||||
|
const crawlable =
|
||||||
|
item.page && item.page >= 1 && item.page <= count && !item.disabled;
|
||||||
|
return (
|
||||||
|
<PaginationItem
|
||||||
|
{...item}
|
||||||
|
{...(crawlable && {
|
||||||
|
component: "a",
|
||||||
|
href: buildHref(item.page),
|
||||||
|
onClick: (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
item.onClick?.(event);
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
"& .MuiPaginationItem-root": {
|
"& .MuiPaginationItem-root": {
|
||||||
color: "#616161",
|
color: "#616161",
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ function PopoverDate({ text, updateDate, type }) {
|
|||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className="!border-[#EFEFEF] dark:!border-[#343645] !bg-[#FFF] dark:!bg-[#222433] !py-[4.5px] !text-[#7E7E7E]
|
className="!border-[#EFEFEF] dark:!border-[#343645] !bg-[#FFF] dark:!bg-[#222433] !py-[4.5px] !text-[#7E7E7E] dark:!text-[#A1A1A1] !text-[16px] !font-normal !w-[115px]"
|
||||||
dark:!text-[#A1A1A1] !text-[16px] !font-normal !w-[115px]"
|
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { numberToArStyle } from "@/helper";
|
import { numberToArStyle } from "@/helper";
|
||||||
import { LinearProgress } from "@mui/material";
|
import { LinearProgress } from "@mui/material";
|
||||||
|
|
||||||
const label = [
|
const fallbackLabels = [
|
||||||
"برخورد مناسب پزشک",
|
"برخورد مناسب پزشک",
|
||||||
"تشخیص درست",
|
"تشخیص درست",
|
||||||
"زمان انتظار در مطب",
|
"زمان انتظار در مطب",
|
||||||
@@ -10,14 +10,18 @@ const label = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function ProgressDetail({ data, idx }) {
|
function ProgressDetail({ data, idx }) {
|
||||||
|
const label = data?.label ?? fallbackLabels[idx];
|
||||||
|
const progress = typeof data === "object" && data !== null ? data.progress : data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex items-center justify-start gap-2 w-full">
|
<li className="flex items-center justify-start gap-2 w-full">
|
||||||
<p className="text-[#525252] text-[14px] font-normal min-w-[120px] w-[118px]">
|
<p className="text-[#525252] text-[14px] font-normal min-w-[120px] w-[118px]">
|
||||||
{label[idx]}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
value={data || 0}
|
value={progress || 0}
|
||||||
variant="determinate"
|
variant="determinate"
|
||||||
|
aria-label={label}
|
||||||
className="!w-full lg:!w-[265px] !rounded-[10px] !bg-[#D7D7D7] !h-[11px]"
|
className="!w-full lg:!w-[265px] !rounded-[10px] !bg-[#D7D7D7] !h-[11px]"
|
||||||
sx={{
|
sx={{
|
||||||
"& .MuiLinearProgress-bar": {
|
"& .MuiLinearProgress-bar": {
|
||||||
@@ -27,9 +31,9 @@ function ProgressDetail({ data, idx }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
className={`text-[#525252] font-medium ${data ? "text-[20px]" : "text-[18px]"}`}
|
className={`text-[#525252] font-medium ${progress ? "text-[20px]" : "text-[18px]"}`}
|
||||||
>
|
>
|
||||||
{data ? `${numberToArStyle(data)}%` : "بدون نمره"}
|
{progress ? `${numberToArStyle(progress)}%` : "بدون نمره"}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Popover } from "@mui/material";
|
||||||
|
import { splitSpecialties } from "@/lib/specialtyDisplay";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تخصص اصلی + شمارندهٔ بقیه در کارت پزشک.
|
||||||
|
*
|
||||||
|
* پیش از این همهٔ نامها پشتسرهم چاپ میشدند و کارتِ پزشکِ ششتخصصی در موبایل
|
||||||
|
* چند برابر بقیه بلند میشد و شبکه را بههم میریخت.
|
||||||
|
*
|
||||||
|
* جدا از ItemDoctor است تا مرز client فقط همینجا باشد؛ کارت از کامپوننتهای
|
||||||
|
* سروری هم رندر میشود.
|
||||||
|
*/
|
||||||
|
function SpecialtyChips({ specialties }) {
|
||||||
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
|
const { primary, rest } = splitSpecialties(specialties);
|
||||||
|
|
||||||
|
if (!primary) return null;
|
||||||
|
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
|
// کارت داخل <Link> است؛ بدون این دو، هر کلیک روی شمارنده صفحهٔ پزشک را باز میکند.
|
||||||
|
const handleOpen = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (event) => {
|
||||||
|
event?.preventDefault?.();
|
||||||
|
event?.stopPropagation?.();
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className="text-[#616161] text-[14px] font-normal truncate">
|
||||||
|
تخصص: {primary.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{rest.length > 0 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpen}
|
||||||
|
aria-label={`نمایش ${rest.length} تخصص دیگر`}
|
||||||
|
className="shrink-0 p-1 bg-[#F8F8FF] rounded-[4px] text-[#616161] text-[12px] font-medium leading-none"
|
||||||
|
>
|
||||||
|
+{rest.length}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||||
|
transformOrigin={{ vertical: "top", horizontal: "right" }}
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
dir="rtl"
|
||||||
|
className="p-3 flex flex-col gap-1.5 max-w-[260px] bg-[#FFF]"
|
||||||
|
>
|
||||||
|
{[primary, ...rest].map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id ?? item.name}
|
||||||
|
className="text-[#616161] text-[13px] font-normal"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SpecialtyChips;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import SpecialtyChips from "@/app/component/SpecialtyChips";
|
||||||
|
|
||||||
|
const root = { id: "13", name: "جراحی عمومی", parent_id: null };
|
||||||
|
const gi = { id: "169", name: "جراح گوارش", parent_id: "13" };
|
||||||
|
const thyroid = { id: "168", name: "جراح تیروئید", parent_id: "13" };
|
||||||
|
|
||||||
|
describe("SpecialtyChips", () => {
|
||||||
|
it("تخصص اصلی را نشان میدهد و بقیه را میشمارد", () => {
|
||||||
|
render(<SpecialtyChips specialties={[gi, root, thyroid]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "نمایش 2 تخصص دیگر" })).toHaveTextContent("+2");
|
||||||
|
|
||||||
|
// بقیه تا پیش از کلیک در DOM نیستند — همین کارت را کوتاه نگه میدارد.
|
||||||
|
expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("کلیک روی شمارنده فهرست کامل را باز میکند", () => {
|
||||||
|
render(<SpecialtyChips specialties={[root, gi, thyroid]} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "نمایش 2 تخصص دیگر" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("جراح گوارش")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("جراح تیروئید")).toBeInTheDocument();
|
||||||
|
// تخصص اصلی هم در فهرست هست تا تصویر کامل باشد.
|
||||||
|
expect(screen.getAllByText(/جراحی عمومی/).length).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("کلیک روی شمارنده نباید لینک کارت را فعال کند", () => {
|
||||||
|
// کارت داخل <Link> است؛ بدون preventDefault/stopPropagation هر بار که کاربر
|
||||||
|
// تخصصها را میبیند به صفحهٔ پزشک پرت میشود.
|
||||||
|
const onParentClick = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Link href="/doctor/uuid" onClick={onParentClick}>
|
||||||
|
<SpecialtyChips specialties={[root, gi]} />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
// fireEvent مقدار false میدهد وقتی preventDefault صدا زده شده باشد.
|
||||||
|
const notPrevented = fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "نمایش 1 تخصص دیگر" })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notPrevented).toBe(false);
|
||||||
|
expect(onParentClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("تکتخصص هیچ شمارندهای ندارد", () => {
|
||||||
|
render(<SpecialtyChips specialties={[root]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("پزشک بدون تخصص چیزی رندر نمیکند و کرش نمیکند", () => {
|
||||||
|
const { container: empty } = render(<SpecialtyChips specialties={[]} />);
|
||||||
|
expect(empty).toBeEmptyDOMElement();
|
||||||
|
|
||||||
|
const { container: missing } = render(<SpecialtyChips specialties={undefined} />);
|
||||||
|
expect(missing).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("شمارنده متن دارد نه فقط علامت — برای صفحهخوان", () => {
|
||||||
|
render(<SpecialtyChips specialties={[root, gi, thyroid]} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button).toHaveAttribute("type", "button");
|
||||||
|
expect(button).toHaveAccessibleName("نمایش 2 تخصص دیگر");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,10 +35,7 @@ function Tabs({ value, handleChange, listTab, inactives, isSm, style }) {
|
|||||||
marginLeft: !isSm ? "9px !important" : undefined,
|
marginLeft: !isSm ? "9px !important" : undefined,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
className={`
|
className={` text-[#495057] !p-1 !text-[14px] md:!text-[16px] !font-bold ${isSm ? "!w-1/2 !m-0" : "!mr-[38px]"} `}
|
||||||
text-[#495057] !p-1 !text-[14px] md:!text-[16px] !font-bold
|
|
||||||
${isSm ? "!w-1/2 !m-0" : "!mr-[38px]"}
|
|
||||||
`}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TabsMUI>
|
</TabsMUI>
|
||||||
|
|||||||
@@ -82,9 +82,7 @@ function TimePickerField({ dataChange, isVacation, disable, timeStart, data }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
className={`absolute cursor-pointer rounded-[8px] dark:bg-[#222433] dark:!text-[#A1A1A1] h-[calc(100%_-_6px)] flex items-center right-[4px] py-[8px] px-[8px]
|
className={`absolute cursor-pointer rounded-[8px] dark:bg-[#222433] dark:!text-[#A1A1A1] h-[calc(100%_-_6px)] flex items-center right-[4px] py-[8px] px-[8px] w-[calc(100%_-_8px)] text-[#7E7E7E] bg-[#FFFFFF] text-[14px] font-normal top-1/2 -translate-y-1/2 ${disable && "!text-[rgba(126,126,126,0.5)]"}`}
|
||||||
w-[calc(100%_-_8px)] text-[#7E7E7E] bg-[#FFFFFF] text-[14px] font-normal top-1/2 -translate-y-1/2
|
|
||||||
${disable && "!text-[rgba(126,126,126,0.5)]"}`}
|
|
||||||
onClick={openTimePicker}
|
onClick={openTimePicker}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
|
|||||||
@@ -47,8 +47,7 @@ function TimePickerInput({ changeData, handleDisableHours, name, data }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
className="absolute dark:bg-[#1F1D2B] md:dark:bg-[#222433] dark:!text-[#A1A1A1] flex items-center right-[4px] h-[44px] py-[8px] px-[8px]
|
className="absolute dark:bg-[#1F1D2B] md:dark:bg-[#222433] dark:!text-[#A1A1A1] flex items-center right-[4px] h-[44px] py-[8px] px-[8px] w-[calc(100%_-_52px)] text-[#7E7E7E] bg-[#FFFFFF] text-[14px] font-normal top-1/2 -translate-y-1/2"
|
||||||
w-[calc(100%_-_52px)] text-[#7E7E7E] bg-[#FFFFFF] text-[14px] font-normal top-1/2 -translate-y-1/2"
|
|
||||||
>
|
>
|
||||||
{data[name]}
|
{data[name]}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import ArrowUpComment from "@/components/icons/ArrowUpComment";
|
|
||||||
import { Button } from "@mui/material";
|
|
||||||
|
|
||||||
function AnswerField({ data, update, setUpdate }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-start gap-1 my-2">
|
|
||||||
<Button
|
|
||||||
className="!text-[#0FA1B3] !text-[12px] !font-medium"
|
|
||||||
onClick={() => {
|
|
||||||
data.is_open_reply = !data.is_open_reply;
|
|
||||||
setUpdate(!update);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
مشاهده پاسخ ها ({data.replies.length})
|
|
||||||
<div
|
|
||||||
className={`!mr-1 ${
|
|
||||||
data.is_open_reply ? "!rotate-0" : "!rotate-180"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<ArrowUpComment />
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AnswerField;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user