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 (
+
+
+
+
+ در حال بهروزرسانی سیستم
+
+
+ {message}
+
+
+
+
+
+ );
+}
+
+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);
// ✅ ساختار خروجی در حالت خطا هم مثل حالت عادی