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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-19 22:01:45 +03:30
co-authored by Claude Fable 5
parent 18484f974d
commit daf38c8631
11 changed files with 393 additions and 4 deletions
+202
View File
@@ -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 و اطمینان از برگشت خودکار سایت.
+5 -1
View File
@@ -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 (
<Layout name="/clinics">
+3
View File
@@ -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);
}
+23
View File
@@ -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 (
<Layout title="در حال به‌روزرسانی سیستم" disableFooter={true}>
<MaintenancePage />
</Layout>
);
}
export default Maintenance;
+4 -1
View File
@@ -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 };
}
});
+69
View File
@@ -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 (
<div className="py-[105px] padding-responsive sm:py-[135px] md:py-[165px] lg:py-[201px] min-h-screen flex flex-col md:flex-row items-center justify-between gap-[50px]">
<div className="flex w-full md:w-fit flex-col justify-center items-center">
<Image
className=" flex md:hidden mt-[20px] w-[200px] sm:w-[270px] md:w-[340px] lg:w-[411px] h-[200px] sm:h-[270px] md:h-[340px] lg:h-[405px] "
src="/assets/images/notfound-cover.png"
alt="maintenance-cover"
height={405}
width={411}
/>
<p className="text-[#3B3B3B] text-[20px] sm:text-[24px] md:text-[28px] lg:text-[32px] font-bold mt-[24px] sm:mt-[30px] md:mt-[35px] lg:mt-[40px] mb-[12px] sm:mb-[16px] md:mb-[20px] lg:mb-[24px] text-center">
در حال بهروزرسانی سیستم
</p>
<p className="text-[#525252] text-[14px] md:text-[16px] font-normal text-center max-w-[520px]">
{message}
</p>
<Button
className="!h-[43px] md:!h-[45px] lg:!h-[46px] !mt-[20px] sm:!mt-[29px] md:!mt-[39px] lg:!mt-[48px] !px-[12px] !text-[#EFEFEF] !text-[14px] md:!text-[16px] !font-medium"
variant="contained"
onClick={() => { window.location.href = "/"; }}
>
تلاش مجدد
</Button>
</div>
<Image
className=" hidden md:flex w-[200px] sm:w-[270px] md:w-[340px] lg:w-[411px] h-[200px] sm:h-[270px] md:h-[340px] lg:h-[405px] "
src="/assets/images/notfound-cover.png"
alt="maintenance-cover"
height={405}
width={411}
/>
</div>
);
}
export default MaintenancePage;
+5 -1
View File
@@ -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 }
+28
View File
@@ -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");
}
+15 -1
View File
@@ -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;
}
+22
View File
@@ -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 &&
+17
View File
@@ -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);
// ✅ ساختار خروجی در حالت خطا هم مثل حالت عادی