feat: implement root domain handling for nobat724.com to prevent city filtering

This commit is contained in:
hamed
2026-07-02 21:53:45 +03:30
parent e6b55c6bad
commit 79f7df2e4d
9 changed files with 196 additions and 24 deletions
@@ -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` همچنان می‌رود.
+4 -5
View File
@@ -25,7 +25,7 @@ export async function generateMetadata() {
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 } = 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;
@@ -35,14 +35,13 @@ export default async function Clinics({ searchParams }) {
// Params // Params
let newSearchParams = awaitedSearchParams; let 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);
+4 -5
View File
@@ -25,7 +25,7 @@ export async function generateMetadata() {
async function Doctors({ searchParams }) { async function Doctors({ searchParams }) {
const awaitedSearchParams = await searchParams; const awaitedSearchParams = await searchParams;
const { matchedCity, matchedState } = await getStateInfo(); const { matchedCity, matchedState, isRoot } = await getStateInfo();
const API_URL = process.env.NEXT_PUBLIC_API_URL; const API_URL = process.env.NEXT_PUBLIC_API_URL;
let doctors = null; let doctors = null;
@@ -35,14 +35,13 @@ async function Doctors({ searchParams }) {
try { try {
let newSearchParams = awaitedSearchParams; let 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 = buildDoctorParams(newSearchParams); const params = buildDoctorParams(newSearchParams);
+4 -4
View File
@@ -20,13 +20,13 @@ export async function generateMetadata() {
} }
async function Specialties() { async function Specialties() {
const { matchedCity, matchedState } = await getStateInfo(); const { matchedCity, matchedState, isRoot } = await getStateInfo();
return ( return (
<Layout name="/specialties"> <Layout name="/specialties">
<SpecialtiesPage <SpecialtiesPage
cityId={matchedCity?.id ?? null} cityId={isRoot ? null : (matchedCity?.id ?? null)}
cityName={matchedCity?.name ?? null} cityName={isRoot ? null : (matchedCity?.name ?? null)}
stateName={matchedState?.name ?? null} stateName={isRoot ? null : (matchedState?.name ?? null)}
/> />
</Layout> </Layout>
); );
+5 -4
View File
@@ -11,18 +11,19 @@ import { useSearchParams } from "next/navigation";
// Data // Data
import states from "@/data/state.json"; import states from "@/data/state.json";
import cities from "@/data/city.json"; import cities from "@/data/city.json";
import { isRootCity } from "@/lib/rootCity";
function ClinicsPage({ clinics, matchedCity, matchedState }) { function ClinicsPage({ clinics, matchedCity, matchedState }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [page, setPage] = useState(clinics?.meta?.currentPage || 1); const [page, setPage] = useState(clinics?.meta?.currentPage || 1);
const [filteredClinics, setFilteredClinics] = useState(clinics?.data); const [filteredClinics, setFilteredClinics] = useState(clinics?.data);
const selectedState = searchParams.get("state") || matchedState?.name; const isRoot = isRootCity(matchedCity);
const selectedState =
searchParams.get("state") || (!isRoot ? matchedState?.name : undefined);
const selectedCity = const selectedCity =
searchParams.get("city") || searchParams.get("city") ||
(matchedCity?.name && matchedCity?.id !== "600" (matchedCity?.name && !isRoot ? matchedCity?.name : false);
? matchedCity?.name
: false);
const [selected, setSelected] = useState({ const [selected, setSelected] = useState({
province: province:
+5 -4
View File
@@ -7,6 +7,7 @@ import Head from "./head";
import specialties from "@/data/specialties.json"; import specialties from "@/data/specialties.json";
import states from "@/data/state.json"; import states from "@/data/state.json";
import cities from "@/data/city.json"; import cities from "@/data/city.json";
import { isRootCity } from "@/lib/rootCity";
function DoctorsPage({ matchedCity, matchedState, list }) { function DoctorsPage({ matchedCity, matchedState, list }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -19,12 +20,12 @@ function DoctorsPage({ matchedCity, matchedState, list }) {
const isParent = const isParent =
findItem("name", fieldName) && findItem("name", fieldName).parent_id; findItem("name", fieldName) && findItem("name", fieldName).parent_id;
const selectedState = searchParams.get("state") || matchedState?.name; const isRoot = isRootCity(matchedCity);
const selectedState =
searchParams.get("state") || (!isRoot ? matchedState?.name : undefined);
const selectedCity = const selectedCity =
searchParams.get("city") || searchParams.get("city") ||
(matchedCity?.name && matchedCity?.id !== "600" (matchedCity?.name && !isRoot ? matchedCity?.name : false);
? matchedCity?.name
: false);
const valCategory = isParent const valCategory = isParent
? findItem("id", isParent) ? findItem("id", isParent)
+4 -1
View File
@@ -2,14 +2,17 @@
import { createContext, useContext, useState, useEffect } from "react"; import { createContext, useContext, useState, useEffect } from "react";
import citiesData from "@/data/city.json"; import citiesData from "@/data/city.json";
import { isRootCity } from "@/lib/rootCity";
const ProvinceContext = createContext(); const ProvinceContext = createContext();
export const ProvinceProvider = ({ children }) => { export const ProvinceProvider = ({ children }) => {
const [hostName, setHostName] = useState(""); const [hostName, setHostName] = useState("");
const isProvinceInclude = citiesData.find((city) => const matched = citiesData.find((city) =>
hostName.includes(city.domain.split(".")[0]), hostName.includes(city.domain.split(".")[0]),
); );
// دامنهٔ ریشه (nobat724) شهر نیست → «شهری انتخاب نشده».
const isProvinceInclude = isRootCity(matched) ? null : matched;
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
+4 -1
View File
@@ -4,6 +4,9 @@ import { headers } from "next/headers";
import citiesData from "@/data/city.json"; import citiesData from "@/data/city.json";
import statesData from "@/data/state.json"; import statesData from "@/data/state.json";
import { ROOT_CITY_ID, isRootCity } from "@/lib/rootCity";
export { ROOT_CITY_ID, isRootCity };
export async function getStateInfo() { export async function getStateInfo() {
const headersList = await headers(); const headersList = await headers();
@@ -20,5 +23,5 @@ export async function getStateInfo() {
matchedCity && matchedCity &&
statesData.find((state) => state.id === matchedCity.province_id); statesData.find((state) => state.id === matchedCity.province_id);
return { matchedCity, matchedState }; return { matchedCity, matchedState, isRoot: isRootCity(matchedCity) };
} }
+5
View File
@@ -0,0 +1,5 @@
// رکورد nobat724.com در city.json دامنهٔ ریشه/مخزنِ همهٔ شهرهاست، نه یک شهر.
// این ماژول pure است (بدون next/headers) تا در client و server هر دو قابل import باشد.
export const ROOT_CITY_ID = 600;
export const isRootCity = (c) => c != null && Number(c.id) === ROOT_CITY_ID;