-
+
{title}
-
+
{detail}
diff --git a/app/doctor/[slug]/loading.js b/app/doctor/[slug]/loading.js
deleted file mode 100644
index e2c1fb0..0000000
--- a/app/doctor/[slug]/loading.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import CircularLoading from "@/app/component/loading/Circular";
-import CustomLoading from "@/app/component/loading/Custom";
-import TextLoading from "@/app/component/loading/Text";
-
-export default function Loading() {
- return (
-
- );
-}
diff --git a/app/doctor/[slug]/page.js b/app/doctor/[slug]/page.js
index 6c8ff71..2286550 100644
--- a/app/doctor/[slug]/page.js
+++ b/app/doctor/[slug]/page.js
@@ -3,7 +3,11 @@ import DoctorPage from "@/components/doctor";
import Layout from "@/components/layout/StLayout";
import { notFound } from "next/navigation";
import { getStateInfo } from "@/lib/getStateInfo";
-import { getRequestOrigin } from "@/lib/getCanonicalUrl";
+import { getEntityOrigin } from "@/lib/getCanonicalUrl";
+import { extractEntityCityId, findCityById } from "@/lib/domainHelpers";
+import { buildDoctorFaq } from "@/lib/specialtyContent";
+import { isThinDoctor } from "@/lib/entityQuality";
+import specialtiesData from "@/data/specialties.json";
import { safeJsonLd } from "@/lib/sanitize";
import { imageUrl } from "@/helper";
@@ -77,12 +81,14 @@ export async function generateMetadata({ params }) {
`رزرو نوبت آنلاین دکتر ${doctor.name}${specialtyNames ? " متخصص " + specialtyNames : ""}${doctor.address ? " | " + doctor.address : ""}`.trim()
).slice(0, 160);
const image = imageUrl(doctor.img?.[0]?.url) || FALLBACK_IMG;
- const isUnclaimed = doctor.owner_status !== "claimed";
+ // C1-b — پزشک به دامنهٔ شهر خودش canonical میشود، روی هر دامنهای که سرو شود.
+ const origin = await getEntityOrigin(extractEntityCityId(doctor));
return {
title,
description,
- ...(isUnclaimed && { robots: { index: false, follow: true } }),
+ alternates: { canonical: `${origin}/doctor/${doctor.uuid}` },
+ ...(isThinDoctor(doctor) && { robots: { index: false, follow: true } }),
openGraph: {
title,
description,
@@ -96,20 +102,24 @@ export async function generateMetadata({ params }) {
images: [image],
},
};
- } catch {
+ } catch (error) {
+ // بازگشت خاموش، canonical و robots صفحه را بیصدا به fallback لایه میبرد — لاگ لازم است.
+ console.error(`[doctor/${slug}] generateMetadata failed:`, error);
return {};
}
}
async function Doctor({ params }) {
const { slug } = await params;
- const origin = await getRequestOrigin();
let comments = null;
let rateAggregate = { point: 0, satisfaction: 0, averages: [] };
const doctor = await getDoctor(slug);
if (!doctor) notFound();
+ // همان مقصد canonical — تا url/@id و breadcrumb سیگنال متناقض ندهند.
+ const origin = await getEntityOrigin(extractEntityCityId(doctor));
+
if (doctor) {
try {
const [resComments, resRate] = await Promise.all([
@@ -229,23 +239,43 @@ async function Doctor({ params }) {
: null;
const specialtyLabel = doctor?.specialties?.[0]?.name || "پزشکان";
+ // مقصد تمیز صفحهٔ فرود تخصص؛ اگر تخصص slug نداشت، به لیست پزشکان برمیگردیم.
+ const specialtySlug = specialtiesData.find(
+ (item) => item.name === specialtyLabel && item.status === 1
+ )?.slug;
+ const specialtyItem = specialtySlug
+ ? `${origin}/specialties/${specialtySlug}`
+ : `${origin}/doctors`;
+
const breadcrumbJsonLd = doctor
? {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
- {
- "@type": "ListItem",
- position: 2,
- name: specialtyLabel,
- item: `${origin}/doctors?specialty=${encodeURIComponent(specialtyLabel)}`,
- },
+ { "@type": "ListItem", position: 2, name: specialtyLabel, item: specialtyItem },
{ "@type": "ListItem", position: 3, name: `دکتر ${doctor.name}` },
],
}
: null;
+ // FAQPage عمداً script مستقل است — اسکیماهای Physician/Review/Geo نباید تحت تأثیر باشند.
+ const doctorCity = findCityById(extractEntityCityId(doctor));
+ const faq = doctor
+ ? buildDoctorFaq(doctor.name, specialtyNames, doctorCity?.name)
+ : [];
+ const faqJsonLd = faq.length
+ ? {
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ mainEntity: faq.map((item) => ({
+ "@type": "Question",
+ name: item.question,
+ acceptedAnswer: { "@type": "Answer", text: item.answer },
+ })),
+ }
+ : null;
+
return (
<>
{jsonLd && (
@@ -254,6 +284,12 @@ async function Doctor({ params }) {
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
/>
)}
+ {faqJsonLd && (
+
+ )}
{breadcrumbJsonLd && (
>
diff --git a/app/doctors/page.js b/app/doctors/page.js
index 952f9d3..d2641f8 100644
--- a/app/doctors/page.js
+++ b/app/doctors/page.js
@@ -3,24 +3,14 @@ import Layout from "@/components/layout/StLayout";
import { buildDoctorParams } from "@/helper";
import { getStateInfo } from "@/lib/getStateInfo";
import { fetchReq } from "@/lib/req";
-
-
-const FILTER_KEYS = ["specialty", "state", "city", "gender", "degree", "active", "sort", "name"];
-
-// جستجوی داخلی و ترکیب چند فیلتر نباید ایندکس شوند — crawl budget و duplicate
-function listingRobots(params) {
- const activeFilters = FILTER_KEYS.filter((key) => params?.[key]);
- if (params?.name || activeFilters.length >= 2) {
- return { robots: { index: false, follow: true } };
- }
- return {};
-}
+import { listingRobots } from "@/lib/listingRobots";
export async function generateMetadata({ searchParams }) {
const awaitedParams = await searchParams;
- const { matchedCity, matchedState, repContext } = await getStateInfo();
+ const { matchedCity, matchedState, isRoot, repContext } = await getStateInfo();
const siteName = matchedCity?.site_name || repContext?.full_name || "نوبت 724";
- const cityName = matchedCity?.name || matchedState?.name || "";
+ // روی دامنهٔ ریشه نام رکورد («نوبت 724») برند است نه شهر — نباید در Title بنشیند.
+ const cityName = isRoot ? "" : matchedCity?.name || matchedState?.name || "";
const title = cityName
? `پزشکان ${cityName} | جستجو و رزرو نوبت | ${siteName}`
: `جستجوی پزشک و رزرو نوبت آنلاین | ${siteName}`;
@@ -31,7 +21,7 @@ export async function generateMetadata({ searchParams }) {
return {
title,
description,
- ...listingRobots(awaitedParams),
+ ...listingRobots(awaitedParams, { matchedCity, matchedState }),
openGraph: { title, description, images: [image] },
twitter: { card: "summary_large_image", title, description, images: [image] },
};
@@ -47,7 +37,9 @@ async function Doctors({ searchParams }) {
const cityParams = awaitedSearchParams.city;
try {
- let newSearchParams = awaitedSearchParams;
+ // کپی — awaitedSearchParams همان شیئی است که generateMetadata هم میخواند؛
+ // تزریق city/state روی خودش، سیاست robots را به noindex میبرد.
+ const newSearchParams = { ...awaitedSearchParams };
// Conditional State — روی دامنهٔ ریشه (nobat724) فیلتر اعمال نمیشود.
if (stateParams) newSearchParams.state = stateParams;
diff --git a/app/sitemap.js b/app/sitemap.js
index 0d9fd3c..c63e19f 100644
--- a/app/sitemap.js
+++ b/app/sitemap.js
@@ -3,6 +3,9 @@ import { getBaseUrl } from '../utils/sitemap';
import citiesData from '@/data/city.json';
import statesData from '@/data/state.json';
import { isRootCity } from '@/lib/rootCity';
+import { extractEntityCityId, findDomainByCityId } from '@/lib/domainHelpers';
+import { isThinClinic, isThinDoctor } from '@/lib/entityQuality';
+import specialtiesData from '@/data/specialties.json';
// sitemap باید در زمان اجرا (per-request) روی سرور production تولید شود، نه در build.
// در build کانتینر به API دسترسی شبکه ندارد → fetch failed. force-dynamic این را قطعی میکند.
@@ -11,8 +14,11 @@ export const revalidate = 3600;
const MAIN_DOMAIN = 'nobat724.com';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
-const PAGE_LIMIT = 500;
-const MAX_PAGES = 40;
+// backend صفحه را به ۵۰ رکورد سقف میزند و limit بزرگتر را نادیده میگیرد.
+// شرط توقف باید meta.totalPages باشد، نه «کمتر از limit برگشت» — وگرنه حلقه در
+// همان صفحهٔ اول میشکند و sitemap روی ۵۰ رکورد بریده میشود.
+const PAGE_LIMIT = 50;
+const MAX_PAGES = 200;
function toSafeDate(value) {
if (!value) return null;
@@ -78,8 +84,13 @@ async function fetchAllPages(path, extraParams = {}) {
const items = Array.isArray(raw) ? raw : Array.isArray(raw?.data) ? raw.data : [];
if (items.length === 0) break;
results.push(...items);
- const total = json?.meta?.totalRecords;
- if (items.length < PAGE_LIMIT || (total && results.length >= Number(total))) break;
+
+ const meta = json?.meta ?? raw?.meta;
+ const totalPages = Number(meta?.totalPages) || 0;
+ const totalRecords = Number(meta?.totalRecords) || 0;
+ if (totalPages && page >= totalPages) break;
+ if (totalRecords && results.length >= totalRecords) break;
+ // بدون meta تنها نشانهٔ پایان، صفحهٔ خالی است (در تکرار بعدی).
}
} catch (error) {
console.error(`[sitemap] failed to fetch ${path} (page skipped):`, error?.message || error);
@@ -95,10 +106,33 @@ function cityFilterParams(scope) {
return params;
}
+// شهرهایی که دامنهٔ اختصاصی دارند — موجودیتهایشان canonical روی همان دامنه دارند و
+// نباید در sitemap دامنهٔ اصلی تکرار شوند.
+const CITY_IDS_WITH_DOMAIN = citiesData
+ .filter((c) => !isRootCity(c) && c.domain)
+ .map((c) => c.id);
+
+/**
+ * پاسخ لیست پزشکان شهر ندارد؛ پس تفکیک با فیلتر سمت API انجام میشود:
+ * روی دامنهٔ شهری با city_id، و روی دامنهٔ اصلی «همه منهای پزشکانِ شهرهای دامنهدار».
+ */
+async function getDoctorsForScope(scope) {
+ if (!scope.isRoot) return fetchAllPages('/api/v1/doctors', cityFilterParams(scope));
+
+ const [all, ...perCity] = await Promise.all([
+ fetchAllPages('/api/v1/doctors'),
+ ...CITY_IDS_WITH_DOMAIN.map((cityId) =>
+ fetchAllPages('/api/v1/doctors', { city_id: String(cityId) })
+ ),
+ ]);
+ const ownedByCityDomain = new Set(perCity.flat().map((d) => d?.uuid).filter(Boolean));
+ return all.filter((d) => !ownedByCityDomain.has(d?.uuid));
+}
+
async function getDoctorUrls(baseUrl, scope) {
- const doctors = await fetchAllPages('/api/v1/doctors', cityFilterParams(scope));
+ const doctors = await getDoctorsForScope(scope);
return doctors
- .filter((d) => d?.uuid && d?.owner_status === 'claimed')
+ .filter((d) => !isThinDoctor(d))
.map((d) =>
withLastModified(
{
@@ -114,7 +148,9 @@ async function getDoctorUrls(baseUrl, scope) {
async function getClinicUrls(baseUrl, scope) {
const clinics = await fetchAllPages('/api/v1/clinics', cityFilterParams(scope));
return clinics
- .filter((c) => c?.uuid)
+ .filter((c) => !isThinClinic(c))
+ // پاسخ لیست کلینیکها شهر دارد — کلینیکِ شهرِ دامنهدار فقط در sitemap همان دامنه.
+ .filter((c) => !scope.isRoot || !findDomainByCityId(extractEntityCityId(c)))
.map((c) =>
withLastModified(
{
@@ -127,10 +163,27 @@ async function getClinicUrls(baseUrl, scope) {
);
}
-async function getBlogUrls(baseUrl) {
+// صفحات فرود تخصص per-domain — روی هر دامنه «تخصص + آن شهر» است، پس همهجا self.
+function getSpecialtyUrls(baseUrl) {
+ return specialtiesData
+ .filter((s) => s?.slug && s.status === 1)
+ .map((s) => ({
+ url: `${baseUrl}/specialties/${s.slug}`,
+ changeFrequency: 'weekly',
+ priority: 0.8,
+ }));
+}
+
+// هر پست فقط در sitemap دامنهٔ canonical خودش: پست شهریافته → دامنهٔ آن شهر،
+// پست سراسری (بدون شهر) → دامنهٔ اصلی.
+async function getBlogUrls(baseUrl, scope, currentDomain) {
const blogs = await fetchAllPages('/api/v1/blogs');
return blogs
.filter((b) => b?.slug || b?.uuid)
+ .filter((b) => {
+ const cityDomain = findDomainByCityId(extractEntityCityId(b));
+ return cityDomain ? cityDomain === currentDomain : scope.isRoot;
+ })
.map((b) =>
withLastModified(
{
@@ -148,15 +201,21 @@ export default async function sitemap() {
const domain = await getCurrentDomain();
const baseUrl = getBaseUrl(domain);
const scope = getCityScope(domain);
+ const normalizedDomain = domain.toLowerCase().replace(/^www\./, '').split(':')[0];
const [doctorUrls, clinicUrls, blogUrls] = await Promise.all([
getDoctorUrls(baseUrl, scope),
getClinicUrls(baseUrl, scope),
- // محتوای بلاگ روی همهی دامنهها یکسان است — فقط دامنهی اصلی آن را در sitemap اعلام میکند
- scope.isRoot ? getBlogUrls(baseUrl) : Promise.resolve([]),
+ getBlogUrls(baseUrl, scope, normalizedDomain),
]);
- const allUrls = [...getStaticPages(baseUrl), ...doctorUrls, ...clinicUrls, ...blogUrls];
+ const allUrls = [
+ ...getStaticPages(baseUrl),
+ ...getSpecialtyUrls(baseUrl),
+ ...doctorUrls,
+ ...clinicUrls,
+ ...blogUrls,
+ ];
const seen = new Set();
return allUrls.filter((item) => {
diff --git a/app/specialties/[slug]/page.js b/app/specialties/[slug]/page.js
new file mode 100644
index 0000000..091576f
--- /dev/null
+++ b/app/specialties/[slug]/page.js
@@ -0,0 +1,135 @@
+import { notFound } from "next/navigation";
+import Layout from "@/components/layout/StLayout";
+import SpecialtyDetailPage from "@/components/specialties/detail";
+import specialtiesData from "@/data/specialties.json";
+import { getStateInfo } from "@/lib/getStateInfo";
+import { getRequestOrigin } from "@/lib/getCanonicalUrl";
+import { resolveCityDisplayName } from "@/lib/domainHelpers";
+import { buildSpecialtyFaq, buildSpecialtyIntro } from "@/lib/specialtyContent";
+import { safeJsonLd } from "@/lib/sanitize";
+import { fetchReq } from "@/lib/req";
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL;
+const DOCTOR_LIMIT = 12;
+
+const findSpecialty = (slug) =>
+ specialtiesData.find((item) => item.slug === slug && item.status === 1) ?? null;
+
+// روی هر دامنهٔ شهری این صفحه خودکار «تخصص + همان شهر» است — شهر از host میآید.
+async function getDoctors(specialtyId, cityId) {
+ try {
+ const res = await fetchReq(`${API_URL}/api/v1/doctors`, {
+ params: {
+ specialty_id: specialtyId,
+ ...(cityId && { city_id: cityId }),
+ page: 1,
+ limit: DOCTOR_LIMIT,
+ },
+ });
+ return {
+ items: res?.data?.data ?? res?.data ?? [],
+ total: Number(res?.data?.meta?.totalRecords ?? res?.meta?.totalRecords ?? 0),
+ };
+ } catch {
+ return { items: [], total: 0 };
+ }
+}
+
+export async function generateMetadata({ params }) {
+ const { slug } = await params;
+ const specialty = findSpecialty(slug);
+ if (!specialty) notFound();
+
+ const { matchedCity, isRoot } = await getStateInfo();
+ const siteName = matchedCity?.site_name || "نوبت 724";
+ const cityName = resolveCityDisplayName(isRoot ? null : matchedCity);
+
+ const title = `متخصص ${specialty.name} در ${cityName} | رزرو نوبت آنلاین | ${siteName}`;
+ const description = `لیست بهترین پزشکان ${specialty.name} در ${cityName} همراه با نشانی مطب، ساعات کاری و امتیاز بیماران. رزرو نوبت آنلاین متخصص ${specialty.name}.`;
+ const image = "/assets/images/og-image.png";
+
+ return {
+ title,
+ description,
+ // C1-a — صفحهٔ فرود تخصص per-domain است؛ canonical لایهٔ layout (self) درست است.
+ openGraph: { title, description, images: [image] },
+ twitter: { card: "summary_large_image", title, description, images: [image] },
+ };
+}
+
+async function SpecialtyDetail({ params }) {
+ const { slug } = await params;
+ const specialty = findSpecialty(slug);
+ if (!specialty) notFound();
+
+ const { matchedCity, isRoot } = await getStateInfo();
+ const origin = await getRequestOrigin();
+ const cityName = resolveCityDisplayName(isRoot ? null : matchedCity);
+
+ const { items: doctors, total } = await getDoctors(
+ specialty.id,
+ isRoot ? null : matchedCity?.id
+ );
+
+ const intro = buildSpecialtyIntro(specialty.name, cityName, total);
+ const faq = buildSpecialtyFaq(specialty.name, cityName, total);
+ const pageUrl = `${origin}/specialties/${specialty.slug}`;
+
+ const pageJsonLd = {
+ "@context": "https://schema.org",
+ "@type": "MedicalWebPage",
+ "@id": `${pageUrl}#webpage`,
+ url: pageUrl,
+ name: `متخصص ${specialty.name} در ${cityName}`,
+ description: intro.slice(0, 300),
+ about: { "@type": "MedicalSpecialty", name: specialty.name },
+ ...(doctors.length > 0 && {
+ mainEntity: {
+ "@type": "ItemList",
+ numberOfItems: doctors.length,
+ itemListElement: doctors.map((doctor, idx) => ({
+ "@type": "ListItem",
+ position: idx + 1,
+ url: `${origin}/doctor/${doctor.uuid}`,
+ name: doctor.name,
+ })),
+ },
+ }),
+ };
+
+ // FAQPage عمداً یک script مستقل است تا اسکیماهای موجود صفحه را نشکند.
+ const faqJsonLd = {
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ mainEntity: faq.map((item) => ({
+ "@type": "Question",
+ name: item.question,
+ acceptedAnswer: { "@type": "Answer", text: item.answer },
+ })),
+ };
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
+
+export default SpecialtyDetail;
diff --git a/app/specialties/page.js b/app/specialties/page.js
index 3993734..f53bbd6 100644
--- a/app/specialties/page.js
+++ b/app/specialties/page.js
@@ -3,9 +3,10 @@ import SpecialtiesPage from "@/components/specialties";
import { getStateInfo } from "@/lib/getStateInfo";
export async function generateMetadata() {
- const { matchedCity } = await getStateInfo();
+ const { matchedCity, isRoot } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724";
- const cityName = matchedCity?.name || "";
+ // روی دامنهٔ ریشه نام رکورد («نوبت 724») برند است نه شهر — نباید در Title بنشیند.
+ const cityName = isRoot ? "" : matchedCity?.name || "";
const title = cityName
? `تخصصهای پزشکی در ${cityName} | ${siteName}`
: `تخصصهای پزشکی | ${siteName}`;
diff --git a/components/blog/head/index.js b/components/blog/head/index.js
index b8cf007..a076eef 100644
--- a/components/blog/head/index.js
+++ b/components/blog/head/index.js
@@ -1,7 +1,7 @@
import Link from "next/link";
import { convertTimestampToJalali } from "@/helper";
-function Head({ data }) {
+function Head({ data, cityName }) {
const firstTag = data?.tag?.[0];
const createdDate = data?.created ? convertTimestampToJalali(data.created) : "";
@@ -34,6 +34,18 @@ function Head({ data }) {