From 79f7df2e4dc5bafbc74bcf1e9050308f95c8b57a Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 2 Jul 2026 21:53:45 +0330 Subject: [PATCH] feat: implement root domain handling for nobat724.com to prevent city filtering --- .../prompt/nobat724-root-domain-not-city.md | 161 ++++++++++++++++++ app/clinics/page.js | 9 +- app/doctors/page.js | 9 +- app/specialties/page.js | 8 +- components/clinics/index.js | 9 +- components/doctors/index.js | 9 +- context/ProvinceProvider.js | 5 +- lib/getStateInfo.js | 5 +- lib/rootCity.js | 5 + 9 files changed, 196 insertions(+), 24 deletions(-) create mode 100644 .claude/prompt/nobat724-root-domain-not-city.md create mode 100644 lib/rootCity.js diff --git a/.claude/prompt/nobat724-root-domain-not-city.md b/.claude/prompt/nobat724-root-domain-not-city.md new file mode 100644 index 0000000..a6b17a9 --- /dev/null +++ b/.claude/prompt/nobat724-root-domain-not-city.md @@ -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])); +// ... + +// روی 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` همچنان می‌رود. diff --git a/app/clinics/page.js b/app/clinics/page.js index e02097e..1351b3d 100644 --- a/app/clinics/page.js +++ b/app/clinics/page.js @@ -25,7 +25,7 @@ export async function generateMetadata() { export default async function Clinics({ 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 stateParams = awaitedSearchParams.state; const cityParams = awaitedSearchParams.city; @@ -35,14 +35,13 @@ export default async function Clinics({ searchParams }) { // Params let newSearchParams = awaitedSearchParams; - // Conditional State + // Conditional State — روی دامنهٔ ریشه (nobat724) فیلتر اعمال نمی‌شود. if (stateParams) newSearchParams.state = stateParams; - else if (matchedState) newSearchParams.state = matchedState.name; + else if (matchedState && !isRoot) newSearchParams.state = matchedState.name; // Conditional City if (cityParams) newSearchParams.city = cityParams; - else if (matchedCity && matchedCity.id !== "600") - newSearchParams.city = matchedCity.name; + else if (matchedCity && !isRoot) newSearchParams.city = matchedCity.name; const params = buildClinicParams(newSearchParams); diff --git a/app/doctors/page.js b/app/doctors/page.js index a880e52..74be23b 100644 --- a/app/doctors/page.js +++ b/app/doctors/page.js @@ -25,7 +25,7 @@ export async function generateMetadata() { async function Doctors({ 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; let doctors = null; @@ -35,14 +35,13 @@ async function Doctors({ searchParams }) { try { let newSearchParams = awaitedSearchParams; - // Conditional State + // Conditional State — روی دامنهٔ ریشه (nobat724) فیلتر اعمال نمی‌شود. if (stateParams) newSearchParams.state = stateParams; - else if (matchedState) newSearchParams.state = matchedState.name; + else if (matchedState && !isRoot) newSearchParams.state = matchedState.name; // Conditional City if (cityParams) newSearchParams.city = cityParams; - else if (matchedCity && matchedCity.id !== "600") - newSearchParams.city = matchedCity.name; + else if (matchedCity && !isRoot) newSearchParams.city = matchedCity.name; const params = buildDoctorParams(newSearchParams); diff --git a/app/specialties/page.js b/app/specialties/page.js index cd7adfb..5054818 100644 --- a/app/specialties/page.js +++ b/app/specialties/page.js @@ -20,13 +20,13 @@ export async function generateMetadata() { } async function Specialties() { - const { matchedCity, matchedState } = await getStateInfo(); + const { matchedCity, matchedState, isRoot } = await getStateInfo(); return ( ); diff --git a/components/clinics/index.js b/components/clinics/index.js index 40d3fb9..fc05ea7 100644 --- a/components/clinics/index.js +++ b/components/clinics/index.js @@ -11,18 +11,19 @@ import { useSearchParams } from "next/navigation"; // Data import states from "@/data/state.json"; import cities from "@/data/city.json"; +import { isRootCity } from "@/lib/rootCity"; function ClinicsPage({ clinics, matchedCity, matchedState }) { const searchParams = useSearchParams(); const [page, setPage] = useState(clinics?.meta?.currentPage || 1); 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 = searchParams.get("city") || - (matchedCity?.name && matchedCity?.id !== "600" - ? matchedCity?.name - : false); + (matchedCity?.name && !isRoot ? matchedCity?.name : false); const [selected, setSelected] = useState({ province: diff --git a/components/doctors/index.js b/components/doctors/index.js index 77cdf1e..951539d 100644 --- a/components/doctors/index.js +++ b/components/doctors/index.js @@ -7,6 +7,7 @@ import Head from "./head"; import specialties from "@/data/specialties.json"; import states from "@/data/state.json"; import cities from "@/data/city.json"; +import { isRootCity } from "@/lib/rootCity"; function DoctorsPage({ matchedCity, matchedState, list }) { const searchParams = useSearchParams(); @@ -19,12 +20,12 @@ function DoctorsPage({ matchedCity, matchedState, list }) { const isParent = 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 = searchParams.get("city") || - (matchedCity?.name && matchedCity?.id !== "600" - ? matchedCity?.name - : false); + (matchedCity?.name && !isRoot ? matchedCity?.name : false); const valCategory = isParent ? findItem("id", isParent) diff --git a/context/ProvinceProvider.js b/context/ProvinceProvider.js index 1485102..dcbaeab 100644 --- a/context/ProvinceProvider.js +++ b/context/ProvinceProvider.js @@ -2,14 +2,17 @@ import { createContext, useContext, useState, useEffect } from "react"; import citiesData from "@/data/city.json"; +import { isRootCity } from "@/lib/rootCity"; const ProvinceContext = createContext(); export const ProvinceProvider = ({ children }) => { const [hostName, setHostName] = useState(""); - const isProvinceInclude = citiesData.find((city) => + const matched = citiesData.find((city) => hostName.includes(city.domain.split(".")[0]), ); + // دامنهٔ ریشه (nobat724) شهر نیست → «شهری انتخاب نشده». + const isProvinceInclude = isRootCity(matched) ? null : matched; useEffect(() => { if (typeof window !== "undefined") { diff --git a/lib/getStateInfo.js b/lib/getStateInfo.js index ddb6de6..6405ece 100644 --- a/lib/getStateInfo.js +++ b/lib/getStateInfo.js @@ -4,6 +4,9 @@ import { headers } from "next/headers"; import citiesData from "@/data/city.json"; import statesData from "@/data/state.json"; +import { ROOT_CITY_ID, isRootCity } from "@/lib/rootCity"; +export { ROOT_CITY_ID, isRootCity }; + export async function getStateInfo() { const headersList = await headers(); @@ -20,5 +23,5 @@ export async function getStateInfo() { matchedCity && statesData.find((state) => state.id === matchedCity.province_id); - return { matchedCity, matchedState }; + return { matchedCity, matchedState, isRoot: isRootCity(matchedCity) }; } diff --git a/lib/rootCity.js b/lib/rootCity.js new file mode 100644 index 0000000..340e646 --- /dev/null +++ b/lib/rootCity.js @@ -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;