From daf38c86315bdab1c49a61af2a9fae732000331e Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 19 Jul 2026 22:01:45 +0330 Subject: [PATCH] feat(maintenance): show maintenance page when the API is in maintenance The backend now answers 503 with code MAINTENANCE_MODE while maintenance is on. Without this change a visitor got a red error toast over a broken page client-side, and a silently empty page server-side, because fetchReq discards the status and returns null on any failure. - lib/maintenance.js detects the state by BOTH status 503 and the error code; a bare 503 can come from a reverse proxy and is not maintenance - The axios interceptor checks it before the 401 branch, so a maintenance response never triggers the refresh-token path or logs the user out - fetchReq redirects to /maintenance, with a silentMaintenance opt-out used by getStateInfo: that one runs inside generateMetadata and while rendering the maintenance page itself, where a redirect is either ineffective or loops - redirect() works by throwing, so the try/catch blocks in the doctors, clinics and specialties pages now rethrow NEXT_REDIRECT instead of swallowing it - clinicApi.js handles 503 too; it previously rendered maintenance as a clinic with zero doctors - The page reuses the existing 404 design and is marked noindex Co-Authored-By: Claude Fable 5 --- .claude/prompt/maintenance-mode-client.md | 202 ++++++++++++++++++++++ app/clinics/page.js | 6 +- app/doctors/page.js | 3 + app/maintenance/page.js | 23 +++ app/specialties/[slug]/page.js | 5 +- components/maintenance/index.js | 69 ++++++++ lib/getStateInfo.js | 6 +- lib/maintenance.js | 28 +++ lib/req.js | 16 +- services/api.js | 22 +++ services/clinicApi.js | 17 ++ 11 files changed, 393 insertions(+), 4 deletions(-) create mode 100644 .claude/prompt/maintenance-mode-client.md create mode 100644 app/maintenance/page.js create mode 100644 components/maintenance/index.js create mode 100644 lib/maintenance.js diff --git a/.claude/prompt/maintenance-mode-client.md b/.claude/prompt/maintenance-mode-client.md new file mode 100644 index 0000000..f0cc898 --- /dev/null +++ b/.claude/prompt/maintenance-mode-client.md @@ -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` | `` در خط 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 ساده که `` را رندر می‌کند +- `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 و اطمینان از برگشت خودکار سایت. diff --git a/app/clinics/page.js b/app/clinics/page.js index 271f3a4..be0865c 100644 --- a/app/clinics/page.js +++ b/app/clinics/page.js @@ -1,4 +1,5 @@ import { fetchReq } from "@/lib/req"; +import { isNextRedirectError } from "@/lib/maintenance"; import ClinicsPage from "@/components/clinics"; import Layout from "@/components/layout/StLayout"; import { getStateInfo } from "@/lib/getStateInfo"; @@ -62,7 +63,10 @@ export default async function Clinics({ searchParams }) { clinics = await fetchReq(`${API_URL}/api/v1/clinics`, { params, }); - } catch (err) { } + } catch (err) { + // ریدایرکت نکست با throw کار می‌کند؛ بدون این rethrow، ریدایرکت حالت تعمیرات بلعیده می‌شود. + if (isNextRedirectError(err)) throw err; + } return ( diff --git a/app/doctors/page.js b/app/doctors/page.js index d2641f8..7e8e0cf 100644 --- a/app/doctors/page.js +++ b/app/doctors/page.js @@ -3,6 +3,7 @@ import Layout from "@/components/layout/StLayout"; import { buildDoctorParams } from "@/helper"; import { getStateInfo } from "@/lib/getStateInfo"; import { fetchReq } from "@/lib/req"; +import { isNextRedirectError } from "@/lib/maintenance"; import { listingRobots } from "@/lib/listingRobots"; export async function generateMetadata({ searchParams }) { @@ -62,6 +63,8 @@ async function Doctors({ searchParams }) { params, }); } catch (error) { + // ریدایرکت نکست با throw کار می‌کند؛ بدون این rethrow، ریدایرکت حالت تعمیرات بلعیده می‌شود. + if (isNextRedirectError(error)) throw error; console.error("Error fetching doctors:", error); } diff --git a/app/maintenance/page.js b/app/maintenance/page.js new file mode 100644 index 0000000..8b1db89 --- /dev/null +++ b/app/maintenance/page.js @@ -0,0 +1,23 @@ +import Layout from "@/components/layout/StLayout"; +import MaintenancePage from "@/components/maintenance"; + +// وضعیت تعمیرات هر لحظه ممکن است عوض شود؛ این صفحه هرگز نباید کش شود. +export const dynamic = "force-dynamic"; + +export async function generateMetadata() { + return { + title: "در حال به‌روزرسانی سیستم", + description: "سامانه موقتاً برای انجام عملیات فنی در دسترس نیست.", + robots: { index: false, follow: false }, + }; +} + +function Maintenance() { + return ( + + + + ); +} + +export default Maintenance; diff --git a/app/specialties/[slug]/page.js b/app/specialties/[slug]/page.js index 7af033a..6b641d8 100644 --- a/app/specialties/[slug]/page.js +++ b/app/specialties/[slug]/page.js @@ -9,6 +9,7 @@ import { resolveCityDisplayName } from "@/lib/domainHelpers"; import { buildSpecialtyFaq, buildSpecialtyIntro } from "@/lib/specialtyContent"; import { safeJsonLd } from "@/lib/sanitize"; import { fetchReq } from "@/lib/req"; +import { isNextRedirectError } from "@/lib/maintenance"; const API_URL = process.env.NEXT_PUBLIC_API_URL; const DOCTOR_LIMIT = 12; @@ -32,7 +33,9 @@ const getDoctors = cache(async (specialtyId, cityId) => { items: res?.data?.data ?? res?.data ?? [], total: Number(res?.data?.meta?.totalRecords ?? res?.meta?.totalRecords ?? 0), }; - } catch { + } catch (error) { + // ریدایرکت نکست با throw کار می‌کند؛ بدون این rethrow، ریدایرکت حالت تعمیرات بلعیده می‌شود. + if (isNextRedirectError(error)) throw error; return { items: [], total: 0 }; } }); diff --git a/components/maintenance/index.js b/components/maintenance/index.js new file mode 100644 index 0000000..b1e13fa --- /dev/null +++ b/components/maintenance/index.js @@ -0,0 +1,69 @@ +"use client"; + +import Image from "next/image"; +import React, { useEffect, useState } from "react"; +import { Button } from "@mui/material"; + +import { DEFAULT_MAINTENANCE_MESSAGE } from "@/lib/maintenance"; + +const RECHECK_INTERVAL_MS = 60_000; + +function MaintenancePage() { + const [message, setMessage] = useState(DEFAULT_MAINTENANCE_MESSAGE); + + // پیام واقعی را interceptor هنگام دریافت ۵۰۳ ذخیره کرده است. + useEffect(() => { + try { + const stored = sessionStorage.getItem("maintenance_message"); + if (stored) setMessage(stored); + } catch { } + }, []); + + // اگر تعمیرات تمام شد کاربر نباید تا رفرش دستی پشت این صفحه بماند. + useEffect(() => { + const timer = setInterval(async () => { + try { + const response = await fetch("/", { method: "HEAD", cache: "no-store" }); + if (response.status !== 503) window.location.href = "/"; + } catch { } + }, RECHECK_INTERVAL_MS); + + return () => clearInterval(timer); + }, []); + + return ( +
+
+ maintenance-cover +

+ در حال به‌روزرسانی سیستم +

+

+ {message} +

+ +
+ maintenance-cover +
+ ); +} + +export default MaintenancePage; diff --git a/lib/getStateInfo.js b/lib/getStateInfo.js index 94d39c4..4ae239d 100644 --- a/lib/getStateInfo.js +++ b/lib/getStateInfo.js @@ -24,8 +24,12 @@ async function fetchSiteContext(host) { let value = null; try { + // این تابع از generateMetadata و از خودِ صفحه‌ی تعمیرات هم صدا زده می‌شود؛ + // ریدایرکت اینجا یا بی‌اثر است یا حلقه می‌سازد. const json = await fetchReq( - `${API_URL}/api/v1/site-context?domain=${encodeURIComponent(host)}` + `${API_URL}/api/v1/site-context?domain=${encodeURIComponent(host)}`, + undefined, + { silentMaintenance: true } ); if (json?.data?.type === "representation") { value = json.data.representation; // { uuid, full_name, is_global } diff --git a/lib/maintenance.js b/lib/maintenance.js new file mode 100644 index 0000000..091d999 --- /dev/null +++ b/lib/maintenance.js @@ -0,0 +1,28 @@ +export const MAINTENANCE_CODE = "MAINTENANCE_MODE"; + +export const MAINTENANCE_PATH = "/maintenance"; + +export const DEFAULT_MAINTENANCE_MESSAGE = + "سامانه موقتاً در دسترس نیست. لطفاً چند دقیقه دیگر مجدداً تلاش کنید."; + +/** + * تشخیص پاسخ حالت تعمیرات backend. + * + * هر دو شرط لازم است: یک ۵۰۳ خالی می‌تواند از reverse proxy یا load balancer هم + * بیاید و آن حالت تعمیرات نیست. + */ +export function isMaintenanceError(error) { + const response = error?.response; + if (response?.status !== 503) return false; + + return response?.data?.errors?.[0]?.code === MAINTENANCE_CODE; +} + +export function maintenanceMessage(data) { + return data?.errors?.[0]?.message ?? DEFAULT_MAINTENANCE_MESSAGE; +} + +/** خطای redirect نکست با throw کار می‌کند و نباید در catchهای عمومی بلعیده شود. */ +export function isNextRedirectError(error) { + return typeof error?.digest === "string" && error.digest.startsWith("NEXT_REDIRECT"); +} diff --git a/lib/req.js b/lib/req.js index b20eb26..3190ff8 100644 --- a/lib/req.js +++ b/lib/req.js @@ -1,5 +1,8 @@ import axios from "axios"; import https from "https"; +import { redirect } from "next/navigation"; + +import { isMaintenanceError, MAINTENANCE_PATH } from "@/lib/maintenance"; export const axiosInstance = axios.create({ ...(process.env.NODE_ENV === "development" && { @@ -7,11 +10,22 @@ export const axiosInstance = axios.create({ }), }); -export const fetchReq = async (url, headers) => { +/** + * @param {object} [options] + * @param {boolean} [options.silentMaintenance] در حالت تعمیرات به‌جای ریدایرکت + * `null` برگردان. برای فراخوانی‌هایی لازم است که داخل `generateMetadata` یا در + * مسیر رندرِ خودِ صفحه‌ی تعمیرات اجرا می‌شوند و ریدایرکت آن‌ها یا بی‌اثر است یا + * حلقه می‌سازد (مثل `getStateInfo`). + */ +export const fetchReq = async (url, headers, options = {}) => { try { const response = await axiosInstance.get(url, headers); return response.data; } catch (error) { + if (isMaintenanceError(error)) { + if (options.silentMaintenance) return null; + redirect(MAINTENANCE_PATH); + } console.error("fetchReq error:", error.message); return null; } diff --git a/services/api.js b/services/api.js index 819dd1a..da7852e 100644 --- a/services/api.js +++ b/services/api.js @@ -2,6 +2,11 @@ import axios from "axios"; import Cookies from "js-cookie"; import { toast } from "react-toastify"; import { getAccessToken, setAccessToken, clearAccessToken } from "@/lib/tokenStore"; +import { + isMaintenanceError, + maintenanceMessage, + MAINTENANCE_PATH, +} from "@/lib/maintenance"; function extractErrorMessage(error) { const errors = error?.response?.data?.errors; @@ -74,6 +79,23 @@ api.interceptors.response.use( async (error) => { const original = error?.config; + // پیش از بررسی ۴۰۱: در حالت تعمیرات نباید مسیر refresh token طی شود و کاربر + // نباید logout شود. toast هم نمایش داده نمی‌شود چون صفحه‌ی تعمیرات جایگزین است. + if (isMaintenanceError(error)) { + if (typeof window !== "undefined") { + try { + sessionStorage.setItem( + "maintenance_message", + maintenanceMessage(error.response.data) + ); + } catch { } + if (window.location.pathname !== MAINTENANCE_PATH) { + window.location.replace(MAINTENANCE_PATH); + } + } + return Promise.reject(error); + } + if ( error?.response?.status === 401 && original?.requireAuth && diff --git a/services/clinicApi.js b/services/clinicApi.js index 72526f1..e242d14 100644 --- a/services/clinicApi.js +++ b/services/clinicApi.js @@ -1,4 +1,12 @@ // src/services/clinicApi.js +import { redirect } from "next/navigation"; + +import { + MAINTENANCE_CODE, + MAINTENANCE_PATH, + isNextRedirectError, +} from "@/lib/maintenance"; + export async function getClinicDoctors(slug, params = {}) { try { const baseUrl = process.env.NEXT_PUBLIC_API_URL; @@ -10,6 +18,14 @@ export async function getClinicDoctors(slug, params = {}) { const response = await fetch(finalUrl, { method: "GET" }); + // بدون این، حالت تعمیرات به‌صورت «کلینیک بدون پزشک» رندر می‌شود که گمراه‌کننده است. + if (response.status === 503) { + const body = await response.json().catch(() => null); + if (body?.errors?.[0]?.code === MAINTENANCE_CODE) { + redirect(MAINTENANCE_PATH); + } + } + if (!response.ok) { const text = await response.text(); throw new Error(`HTTP ${response.status}: ${text}`); @@ -29,6 +45,7 @@ export async function getClinicDoctors(slug, params = {}) { : { total_pages: 1, current: 1 }, }; } catch (error) { + if (isNextRedirectError(error)) throw error; console.error("❌ خطا در دریافت دکترهای کلینیک:", error); // ✅ ساختار خروجی در حالت خطا هم مثل حالت عادی