diff --git a/.claude/prompt/nobat724-test-suite.md b/.claude/prompt/nobat724-test-suite.md
new file mode 100644
index 0000000..27a4f47
--- /dev/null
+++ b/.claude/prompt/nobat724-test-suite.md
@@ -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 {String(cityId)}; }
+// Object.defineProperty(window,'location',{ value:{ hostname:'arak-nobat.ir' }, writable:true });
+// render(
سلام
'); + expect(out).toContain('سلام
'); + expect(out).not.toContain('