feat: implement root domain handling for nobat724.com to prevent city filtering
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user