Implement multi-domain SEO improvements:

- Add metadata to login and login-verify pages to prevent indexing.
- Update robots.txt to disallow additional sensitive paths.
- Enhance sitemap generation to filter by city and include accurate last modified dates.
- Refactor canonical URL generation to support multi-domain architecture, ensuring self-canonicalization for city domains.
- Remove deprecated CanonicalHandler component and streamline canonical URL handling.
- Introduce safe JSON-LD output to prevent XSS vulnerabilities.
- Add payment layout with appropriate metadata to prevent indexing.
- Conduct a comprehensive technical SEO audit and implement necessary fixes across the application.
This commit is contained in:
hamed
2026-07-05 15:47:47 +03:30
parent 522caf6b1b
commit b04bb45ca1
24 changed files with 595 additions and 329 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ export async function generateMetadata() {
const siteName = matchedCity?.site_name || "نوبت 724";
const title = `درباره ما | ${siteName}`;
const description = `آشنایی با ${siteName}، سیستم آنلاین نوبت‌دهی پزشکی. هدف ما ارائه خدمات سریع و کارآمد رزرو نوبت پزشکی برای همه مردم است.`;
const image = "https://www.nobat724.com/assets/images/logo.png";
const image = "https://nobat724.com/assets/images/logo.png";
return {
title,
description,
+4
View File
@@ -2,6 +2,10 @@ import AppointmentPage from "@/components/appointment";
import { getStateInfo } from "@/lib/getStateInfo";
import { axiosInstance } from "@/lib/req";
export const metadata = {
robots: { index: false, follow: false },
};
async function Appointment({ params }) {
const { doctorId } = await params;
const { matchedCity } = await getStateInfo();
+12 -5
View File
@@ -1,11 +1,14 @@
import { cache } from "react";
import BlogPage from "@/components/blog";
import Layout from "@/components/layout/StLayout";
import { notFound } from "next/navigation";
import { fetchReq } from "@/lib/req";
import { getStateInfo } from "@/lib/getStateInfo";
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
import { safeJsonLd } from "@/lib/sanitize";
import { normalizeBlog, imageUrl } from "@/helper";
const FALLBACK_IMG = "https://www.nobat724.com/assets/images/logo.png";
const FALLBACK_IMG = "https://nobat724.com/assets/images/logo.png";
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const getBlog = cache(async (slug) => {
@@ -40,6 +43,8 @@ export async function generateMetadata({ params }) {
return {
title,
description,
// محتوای بلاگ روی همه‌ی دامنه‌های شهری یکسان است — تجمیع اعتبار روی دامنه‌ی اصلی
alternates: { canonical: `https://nobat724.com/blog/${slug}` },
openGraph: {
title,
description,
@@ -63,8 +68,10 @@ export async function generateMetadata({ params }) {
async function Blog({ params }) {
const { slug } = await params;
const origin = await getRequestOrigin();
const blog = normalizeBlog(await getBlog(slug));
if (!blog) notFound();
let relatedBlogs = [];
const relatedResponse = await fetchReq(
@@ -93,8 +100,8 @@ async function Blog({ params }) {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "خانه", item: "https://www.nobat724.com" },
{ "@type": "ListItem", position: 2, name: "مقالات", item: "https://www.nobat724.com/blogs" },
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
{ "@type": "ListItem", position: 2, name: "مقالات", item: `${origin}/blogs` },
{ "@type": "ListItem", position: 3, name: blog.title },
],
}
@@ -105,13 +112,13 @@ async function Blog({ params }) {
{jsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
/>
)}
{breadcrumbJsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }}
/>
)}
<Layout>
+3 -1
View File
@@ -7,10 +7,12 @@ export async function generateMetadata() {
const siteName = matchedCity?.site_name || "نوبت 724";
const title = `مقالات و اخبار پزشکی | ${siteName}`;
const description = `جدیدترین مقالات، اخبار و راهنماهای پزشکی. اطلاعات تخصصی در حوزه سلامت و پزشکی از متخصصان ${siteName}.`;
const image = "https://www.nobat724.com/assets/images/logo.png";
const image = "https://nobat724.com/assets/images/logo.png";
return {
title,
description,
// مقالات روی همه‌ی دامنه‌ها یکسان‌اند — تجمیع روی دامنه‌ی اصلی
alternates: { canonical: "https://nobat724.com/blogs" },
openGraph: { title, description, images: [image] },
twitter: { card: "summary_large_image", title, description, images: [image] },
};
+11 -6
View File
@@ -1,8 +1,11 @@
import { cache } from "react";
import ClinicPage from "@/components/clinic";
import Layout from "@/components/layout/StLayout";
import { notFound } from "next/navigation";
import { fetchReq } from "@/lib/req";
import { getStateInfo } from "@/lib/getStateInfo";
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
import { safeJsonLd } from "@/lib/sanitize";
import { imageUrl } from "@/helper";
const API_URL = process.env.NEXT_PUBLIC_API_URL;
@@ -34,7 +37,7 @@ export async function generateMetadata({ params }) {
const image = clinic.images_clinic?.[0]?.url
? [imageUrl(clinic.images_clinic[0].url)]
: ["https://www.nobat724.com/assets/images/logo.png"];
: ["https://nobat724.com/assets/images/logo.png"];
return {
title,
@@ -72,11 +75,13 @@ function computeClinicRating(doctors) {
async function Clinic({ params, searchParams }) {
const { slug } = await params;
const origin = await getRequestOrigin();
const sp = await searchParams;
const page = Number(sp?.page) || 1;
const limit = 50;
const clinic = await getClinic(slug);
if (!clinic) notFound();
const reqDoctors = await fetchReq(
`${API_URL}/api/v1/clinic/doctor-list/${slug}?page=${page}&limit=${limit}`
);
@@ -104,7 +109,7 @@ async function Clinic({ params, searchParams }) {
"@context": "https://schema.org",
"@type": "MedicalClinic",
name: clinic.title,
url: `https://www.nobat724.com/clinic/${clinic.uuid}`,
url: `${origin}/clinic/${clinic.uuid}`,
...(clinic.images_clinic?.[0]?.url && {
image: {
"@type": "ImageObject",
@@ -150,8 +155,8 @@ async function Clinic({ params, searchParams }) {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "خانه", item: "https://www.nobat724.com" },
{ "@type": "ListItem", position: 2, name: "کلینیک‌ها", item: "https://www.nobat724.com/clinics" },
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
{ "@type": "ListItem", position: 2, name: "کلینیک‌ها", item: `${origin}/clinics` },
{ "@type": "ListItem", position: 3, name: clinic.title },
],
}
@@ -162,13 +167,13 @@ async function Clinic({ params, searchParams }) {
{jsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
/>
)}
{breadcrumbJsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }}
/>
)}
<Layout>
+16 -2
View File
@@ -4,7 +4,20 @@ import Layout from "@/components/layout/StLayout";
import { getStateInfo } from "@/lib/getStateInfo";
import { buildClinicParams } from "@/helper";
export async function generateMetadata() {
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 {};
}
export async function generateMetadata({ searchParams }) {
const awaitedParams = await searchParams;
const { matchedCity, matchedState } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724";
const cityName = matchedCity?.name || matchedState?.name || "";
@@ -14,10 +27,11 @@ export async function generateMetadata() {
const description = cityName
? `لیست کلینیک‌ها و مراکز درمانی در ${cityName}. رزرو آنلاین نوبت از بهترین مراکز درمانی ${cityName}.`
: `جستجوی کلینیک‌ها و مراکز درمانی در سراسر کشور. رزرو آنلاین نوبت سریع و آسان.`;
const image = "https://www.nobat724.com/assets/images/logo.png";
const image = "https://nobat724.com/assets/images/logo.png";
return {
title,
description,
...listingRobots(awaitedParams),
openGraph: { title, description, images: [image] },
twitter: { card: "summary_large_image", title, description, images: [image] },
};
+30 -1
View File
@@ -1,11 +1,23 @@
import { Pagination, useMediaQuery, useTheme } from "@mui/material";
"use client";
import { Pagination, PaginationItem, useMediaQuery, useTheme } from "@mui/material";
import { usePathname, useSearchParams } from "next/navigation";
function PaginationContent({ page, pageDetail, changePage, limit = 12 }) {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("sm"));
const pathname = usePathname();
const searchParams = useSearchParams();
const count = Math.ceil(pageDetail.totalRecords / limit);
// href واقعی برای خزنده‌ها؛ کلیک کاربر همچنان SPA می‌ماند (preventDefault + onChange)
const buildHref = (pageNum) => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", String(pageNum));
return `${pathname}?${params.toString()}`;
};
return (
<Pagination
count={count}
@@ -14,6 +26,23 @@ function PaginationContent({ page, pageDetail, changePage, limit = 12 }) {
onChange={changePage}
color="primary"
className={(!count || count < 2) && "!hidden"}
renderItem={(item) => {
const crawlable =
item.page && item.page >= 1 && item.page <= count && !item.disabled;
return (
<PaginationItem
{...item}
{...(crawlable && {
component: "a",
href: buildHref(item.page),
onClick: (event) => {
event.preventDefault();
item.onClick?.(event);
},
})}
/>
);
}}
sx={{
"& .MuiPaginationItem-root": {
color: "#616161",
+4
View File
@@ -11,6 +11,10 @@ import { safeJsonParse } from "@/lib/sanitize";
import { buildPatientUser } from "@/lib/representationAdapters";
import { getServerAccessToken } from "@/lib/serverToken";
export const metadata = {
robots: { index: false, follow: false },
};
export default async function Dashboard({ searchParams }) {
const awaitedSearchParams = await searchParams;
const { matchedCity } = await getStateInfo();
+12 -7
View File
@@ -1,11 +1,14 @@
import { cache } from "react";
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 { safeJsonLd } from "@/lib/sanitize";
import { imageUrl } from "@/helper";
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const FALLBACK_IMG = "https://www.nobat724.com/assets/images/logo.png";
const FALLBACK_IMG = "https://nobat724.com/assets/images/logo.png";
const getDoctor = cache(async (slug) => {
try {
@@ -72,10 +75,12 @@ export async function generateMetadata({ params }) {
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();
if (doctor) {
try {
@@ -99,9 +104,9 @@ async function Doctor({ params }) {
? {
"@context": "https://schema.org",
"@type": "Physician",
"@id": `https://www.nobat724.com/doctor/${doctor.uuid}#physician`,
"@id": `${origin}/doctor/${doctor.uuid}#physician`,
name: `دکتر ${doctor.name}`,
url: `https://www.nobat724.com/doctor/${doctor.uuid}`,
url: `${origin}/doctor/${doctor.uuid}`,
...(specialtyNames && { medicalSpecialty: specialtyNames }),
...(doctor.img?.[0]?.url && {
image: {
@@ -155,12 +160,12 @@ async function Doctor({ params }) {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "خانه", item: "https://www.nobat724.com" },
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
{
"@type": "ListItem",
position: 2,
name: specialtyLabel,
item: `https://www.nobat724.com/doctors?specialty=${encodeURIComponent(specialtyLabel)}`,
item: `${origin}/doctors?specialty=${encodeURIComponent(specialtyLabel)}`,
},
{ "@type": "ListItem", position: 3, name: `دکتر ${doctor.name}` },
],
@@ -172,13 +177,13 @@ async function Doctor({ params }) {
{jsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
/>
)}
{breadcrumbJsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }}
/>
)}
<Layout>
+16 -2
View File
@@ -4,7 +4,20 @@ import { buildDoctorParams } from "@/helper";
import { getStateInfo } from "@/lib/getStateInfo";
import { fetchReq } from "@/lib/req";
export async function generateMetadata() {
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 {};
}
export async function generateMetadata({ searchParams }) {
const awaitedParams = await searchParams;
const { matchedCity, matchedState } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724";
const cityName = matchedCity?.name || matchedState?.name || "";
@@ -14,10 +27,11 @@ export async function generateMetadata() {
const description = cityName
? `لیست پزشکان متخصص در ${cityName}. جستجو بر اساس تخصص و منطقه. رزرو آنلاین نوبت پزشکی در ${cityName}.`
: `جستجوی پزشکان متخصص در سراسر کشور. رزرو آنلاین نوبت پزشکی سریع و آسان با نوبت 724.`;
const image = "https://www.nobat724.com/assets/images/logo.png";
const image = "https://nobat724.com/assets/images/logo.png";
return {
title,
description,
...listingRobots(awaitedParams),
openGraph: { title, description, images: [image] },
twitter: { card: "summary_large_image", title, description, images: [image] },
};
+9 -9
View File
@@ -6,63 +6,63 @@
@font-face {
font-family: vazir;
src: url(../public/fonts/vazir/Vazirmatn-RD-FD-Thin.ttf);
src: url(/fonts/vazir/Vazirmatn-RD-FD-Thin.ttf);
font-weight: 100;
font-display: swap;
}
@font-face {
font-family: vazir;
src: url(../public/fonts/vazir/Vazirmatn-RD-FD-ExtraLight.ttf);
src: url(/fonts/vazir/Vazirmatn-RD-FD-ExtraLight.ttf);
font-weight: 200;
font-display: swap;
}
@font-face {
font-family: vazir;
src: url(../public/fonts/vazir/Vazirmatn-RD-FD-Light.ttf);
src: url(/fonts/vazir/Vazirmatn-RD-FD-Light.ttf);
font-weight: 300;
font-display: swap;
}
@font-face {
font-family: vazir;
src: url(../public/fonts/vazir/Vazirmatn-RD-FD-Regular.ttf);
src: url(/fonts/vazir/Vazirmatn-RD-FD-Regular.ttf);
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: vazir;
src: url(../public/fonts/vazir/Vazirmatn-RD-FD-Medium.ttf);
src: url(/fonts/vazir/Vazirmatn-RD-FD-Medium.ttf);
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: vazir;
src: url(../public/fonts/vazir/Vazirmatn-RD-FD-SemiBold.ttf);
src: url(/fonts/vazir/Vazirmatn-RD-FD-SemiBold.ttf);
font-weight: 600;
font-display: swap;
}
@font-face {
font-family: vazir;
src: url(../public/fonts/vazir/Vazirmatn-RD-FD-Bold.ttf);
src: url(/fonts/vazir/Vazirmatn-RD-FD-Bold.ttf);
font-weight: 700;
font-display: swap;
}
@font-face {
font-family: vazir;
src: url(../public/fonts/vazir/Vazirmatn-RD-FD-ExtraBold.ttf);
src: url(/fonts/vazir/Vazirmatn-RD-FD-ExtraBold.ttf);
font-weight: 800;
font-display: swap;
}
@font-face {
font-family: vazir;
src: url(../public/fonts/vazir/Vazirmatn-RD-FD-Black.ttf);
src: url(/fonts/vazir/Vazirmatn-RD-FD-Black.ttf);
font-weight: 900;
font-display: swap;
}
+31 -11
View File
@@ -6,11 +6,21 @@ import "react-toastify/dist/ReactToastify.css";
import CustomToastify from "./CustomToastify";
import { ProvinceProvider } from "@/context/ProvinceProvider";
import { getStateInfo } from "@/lib/getStateInfo";
import { getCanonicalUrl } from "@/lib/getCanonicalUrl";
import { getCanonicalUrl, buildCanonicalUrl, getRequestOrigin } from "@/lib/getCanonicalUrl";
import { safeJsonLd } from "@/lib/sanitize";
import { headers } from "next/headers";
export const viewport = {
width: "device-width",
initialScale: 1,
};
export async function generateMetadata() {
const { matchedCity } = await getStateInfo();
const headersList = await headers();
const metadataBase = new URL(buildCanonicalUrl(headersList.get("host"), "/"));
const canonicalUrl = await getCanonicalUrl();
const title =
@@ -21,6 +31,7 @@ export async function generateMetadata() {
const description = matchedCity?.description || "نوبت 724 - سیستم آنلاین نوبت‌دهی برای پزشکان و کلینیک‌ها. با استفاده از نوبت 724، به راحتی نوبت پزشکی خود را به صورت آنلاین رزرو کنید و از خدمات سریع و کارآمد ما بهره‌مند شوید";
const baseMetadata = {
metadataBase,
title,
description,
keywords: matchedCity
@@ -32,13 +43,13 @@ export async function generateMetadata() {
type: "website",
locale: "fa_IR",
siteName: matchedCity?.site_name || "نوبت 724",
images: ["https://www.nobat724.com/assets/images/logo.png"],
images: ["https://nobat724.com/assets/images/logo.png"],
},
twitter: {
card: "summary_large_image",
title,
description,
images: ["https://www.nobat724.com/assets/images/logo.png"],
images: ["https://nobat724.com/assets/images/logo.png"],
},
};
@@ -59,8 +70,7 @@ export async function generateMetadata() {
};
}
// Add canonical URL only if we're not on the main domain and matchedCity domain is not nobat724.com
if (canonicalUrl && (!matchedCity || matchedCity.domain !== 'nobat724.com')) {
if (canonicalUrl) {
baseMetadata.alternates = {
canonical: canonicalUrl,
};
@@ -72,7 +82,7 @@ export async function generateMetadata() {
export default async function RootLayout({ children }) {
const { matchedCity } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724";
const siteUrl = "https://www.nobat724.com";
const siteUrl = await getRequestOrigin();
const organizationJsonLd = {
"@context": "https://schema.org",
@@ -97,17 +107,27 @@ export default async function RootLayout({ children }) {
return (
<html lang="fa" dir="rtl" suppressHydrationWarning>
<head>
<meta
name="viewport"
content="width=device-width,initial-scale=1"
<link
rel="preload"
href="/fonts/vazir/Vazirmatn-RD-FD-Regular.ttf"
as="font"
type="font/ttf"
crossOrigin="anonymous"
/>
<link
rel="preload"
href="/fonts/vazir/Vazirmatn-RD-FD-Bold.ttf"
as="font"
type="font/ttf"
crossOrigin="anonymous"
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(organizationJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(websiteJsonLd) }}
/>
</head>
<ThemeRegistry>
+4
View File
@@ -1,5 +1,9 @@
import ContentVerify from "@/components/register/ContentVerify";
export const metadata = {
robots: { index: false, follow: false },
};
function LoginVerify() {
return <ContentVerify />;
}
+4
View File
@@ -3,6 +3,10 @@ import { defineAbilitiesFor } from "@/lib/ability";
import { getUser } from "@/lib/auth";
import { redirect } from "next/navigation";
export const metadata = {
robots: { index: false, follow: false },
};
async function LogIn() {
const user = await getUser();
const ability = defineAbilitiesFor(user);
+7
View File
@@ -0,0 +1,7 @@
export const metadata = {
robots: { index: false, follow: false },
};
export default function PaymentLayout({ children }) {
return children;
}
+18 -21
View File
@@ -1,25 +1,17 @@
import { headers } from 'next/headers';
import { getBaseUrl } from '../utils/sitemap';
// Get current domain from headers
function getCurrentDomain() {
const headersList = headers();
const host = headersList.get('host');
return host || 'nobat724.com';
async function getCurrentDomain() {
try {
const headersList = await headers();
return headersList.get('host') || 'nobat724.com';
} catch {
return 'nobat724.com';
}
}
// Get base URL based on current domain
function getBaseUrl(domain) {
// Remove port number if present (for development)
const cleanDomain = domain.replace(/:\d+$/, '');
// For localhost domains, use http, otherwise https
const protocol = cleanDomain.includes('localhost') ? 'http' : 'https';
return `${protocol}://${domain}`;
}
export default function robots() {
const domain = getCurrentDomain();
export default async function robots() {
const domain = await getCurrentDomain();
const baseUrl = getBaseUrl(domain);
// If DEV_MODE is TRUE, disallow all crawlers
@@ -32,13 +24,18 @@ export default function robots() {
};
}
// Normal behavior when DEV_MODE is FALSE or not set
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/dashboard'],
disallow: [
'/dashboard',
'/login',
'/login-verify',
'/payment',
'/appointment',
],
},
sitemap: `${baseUrl}/sitemap.xml`,
};
}
}
+125 -89
View File
@@ -1,127 +1,163 @@
import { headers } from 'next/headers';
import { getBaseUrl } from '../utils/sitemap';
function toSafeDate(value) {
if (!value) return new Date();
const d = new Date(value);
return isNaN(d.getTime()) ? new Date() : d;
}
import citiesData from '@/data/city.json';
import statesData from '@/data/state.json';
import { isRootCity } from '@/lib/rootCity';
const MAIN_DOMAIN = 'nobat724.com';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const PAGE_LIMIT = 500;
const MAX_PAGES = 40;
function getCurrentDomain() {
function toSafeDate(value) {
if (!value) return null;
const d = new Date(typeof value === 'number' ? value * 1000 : value);
return isNaN(d.getTime()) ? null : d;
}
function withLastModified(entry, dateValue) {
const lastModified = toSafeDate(dateValue);
return lastModified ? { ...entry, lastModified } : entry;
}
async function getCurrentDomain() {
try {
const headersList = headers();
const host = headersList.get('host');
return host || MAIN_DOMAIN;
const headersList = await headers();
return headersList.get('host') || MAIN_DOMAIN;
} catch {
return MAIN_DOMAIN;
}
}
const NOW = new Date();
// همان منطق lib/getStateInfo.js — اینجا host را از آرگومان می‌گیریم نه دوباره از headers
function getCityScope(host) {
const subdomain = (host || '').toLowerCase().replace(/^www\./, '').split('.')[0];
const matchedCity = citiesData.find(
(city) => city.domain.split('.')[0] === subdomain
);
const matchedState =
matchedCity && statesData.find((state) => state.id === matchedCity.province_id);
const isRoot = !matchedCity || isRootCity(matchedCity);
return { matchedCity, matchedState, isRoot };
}
function getStaticPages(baseUrl) {
return [
{ url: baseUrl, lastModified: NOW, changeFrequency: 'daily', priority: 1 },
{ url: `${baseUrl}/about-us`, lastModified: NOW, changeFrequency: 'monthly', priority: 0.8 },
{ url: `${baseUrl}/contact-us`, lastModified: NOW, changeFrequency: 'monthly', priority: 0.8 },
{ url: `${baseUrl}/blogs`, lastModified: NOW, changeFrequency: 'weekly', priority: 0.9 },
{ url: `${baseUrl}/doctors`, lastModified: NOW, changeFrequency: 'daily', priority: 0.9 },
{ url: `${baseUrl}/clinics`, lastModified: NOW, changeFrequency: 'daily', priority: 0.9 },
{ url: `${baseUrl}/specialties`, lastModified: NOW, changeFrequency: 'weekly', priority: 0.9 },
{ url: baseUrl, changeFrequency: 'daily', priority: 1 },
{ url: `${baseUrl}/about-us`, changeFrequency: 'monthly', priority: 0.8 },
{ url: `${baseUrl}/contact-us`, changeFrequency: 'monthly', priority: 0.8 },
{ url: `${baseUrl}/blogs`, changeFrequency: 'weekly', priority: 0.9 },
{ url: `${baseUrl}/doctors`, changeFrequency: 'daily', priority: 0.9 },
{ url: `${baseUrl}/clinics`, changeFrequency: 'daily', priority: 0.9 },
{ url: `${baseUrl}/specialties`, changeFrequency: 'weekly', priority: 0.9 },
];
}
async function getDoctorUrls(baseUrl) {
const API_URL = process.env.NEXT_PUBLIC_API_URL;
async function fetchAllPages(path, extraParams = {}) {
if (!API_URL) return [];
const results = [];
try {
const res = await fetch(`${API_URL}/api/v1/doctors?page=1&limit=2000`, {
next: { revalidate: 3600 },
});
if (!res.ok) return [];
const data = await res.json();
const doctors = data?.data || data?.doctors || data || [];
return doctors
.filter((d) => d?.uuid)
.map((d) => ({
url: `${baseUrl}/doctor/${d.uuid}`,
lastModified: NOW,
changeFrequency: 'weekly',
priority: 0.8,
}));
} catch {
return [];
for (let page = 1; page <= MAX_PAGES; page++) {
const search = new URLSearchParams({
...extraParams,
page: String(page),
limit: String(PAGE_LIMIT),
});
const res = await fetch(`${API_URL}${path}?${search.toString()}`, {
next: { revalidate: 3600 },
});
if (!res.ok) break;
const json = await res.json();
const raw = json?.data ?? json;
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;
}
} catch (error) {
console.error(`Error fetching sitemap data from ${path}:`, error);
}
return results;
}
async function getClinicUrls(baseUrl) {
const API_URL = process.env.NEXT_PUBLIC_API_URL;
if (!API_URL) return [];
function cityFilterParams(scope) {
if (scope.isRoot) return {};
const params = {};
if (scope.matchedState?.id) params.state_id = String(scope.matchedState.id);
if (scope.matchedCity?.id) params.city_id = String(scope.matchedCity.id);
return params;
}
try {
const res = await fetch(`${API_URL}/api/v1/clinics?page=1&limit=2000`, {
next: { revalidate: 3600 },
});
if (!res.ok) return [];
const data = await res.json();
const clinics = data?.data || data?.clinics || data || [];
return clinics
.filter((c) => c?.uuid)
.map((c) => ({
url: `${baseUrl}/clinic/${c.uuid}`,
lastModified: NOW,
changeFrequency: 'weekly',
priority: 0.7,
}));
} catch {
return [];
}
async function getDoctorUrls(baseUrl, scope) {
const doctors = await fetchAllPages('/api/v1/doctors', cityFilterParams(scope));
return doctors
.filter((d) => d?.uuid)
.map((d) =>
withLastModified(
{
url: `${baseUrl}/doctor/${d.uuid}`,
changeFrequency: 'weekly',
priority: 0.8,
},
d.updated || d.created
)
);
}
async function getClinicUrls(baseUrl, scope) {
const clinics = await fetchAllPages('/api/v1/clinics', cityFilterParams(scope));
return clinics
.filter((c) => c?.uuid)
.map((c) =>
withLastModified(
{
url: `${baseUrl}/clinic/${c.uuid}`,
changeFrequency: 'weekly',
priority: 0.7,
},
c.updated || c.created
)
);
}
async function getBlogUrls(baseUrl) {
const API_URL = process.env.NEXT_PUBLIC_API_URL;
if (!API_URL) return [];
try {
const res = await fetch(`${API_URL}/api/v1/blogs?page=1&limit=2000`, {
next: { revalidate: 3600 },
});
if (!res.ok) return [];
const data = await res.json();
const blogs = data?.blogs || data?.data || data || [];
return blogs
.filter((b) => b?.slug || b?.uuid)
.map((b) => ({
url: `${baseUrl}/blog/${b.slug || b.uuid}`,
lastModified: toSafeDate(b.created),
changeFrequency: 'monthly',
priority: 0.6,
}));
} catch {
return [];
}
const blogs = await fetchAllPages('/api/v1/blogs');
return blogs
.filter((b) => b?.slug || b?.uuid)
.map((b) =>
withLastModified(
{
url: `${baseUrl}/blog/${b.slug || b.uuid}`,
changeFrequency: 'monthly',
priority: 0.6,
},
b.updated || b.created
)
);
}
export default async function sitemap() {
try {
const domain = getCurrentDomain();
const domain = await getCurrentDomain();
const baseUrl = getBaseUrl(domain);
const scope = getCityScope(domain);
const [staticPages, doctorUrls, clinicUrls, blogUrls] = await Promise.all([
Promise.resolve(getStaticPages(baseUrl)),
getDoctorUrls(baseUrl),
getClinicUrls(baseUrl),
getBlogUrls(baseUrl),
const [doctorUrls, clinicUrls, blogUrls] = await Promise.all([
getDoctorUrls(baseUrl, scope),
getClinicUrls(baseUrl, scope),
// محتوای بلاگ روی همه‌ی دامنه‌ها یکسان است — فقط دامنه‌ی اصلی آن را در sitemap اعلام می‌کند
scope.isRoot ? getBlogUrls(baseUrl) : Promise.resolve([]),
]);
const allUrls = [...staticPages, ...doctorUrls, ...clinicUrls, ...blogUrls];
const allUrls = [...getStaticPages(baseUrl), ...doctorUrls, ...clinicUrls, ...blogUrls];
return allUrls.filter(
(item, index, arr) => arr.findIndex((i) => i.url === item.url) === index
);
const seen = new Set();
return allUrls.filter((item) => {
if (seen.has(item.url)) return false;
seen.add(item.url);
return true;
});
} catch (error) {
console.error('Error generating sitemap:', error);
return [];
+1 -1
View File
@@ -10,7 +10,7 @@ export async function generateMetadata() {
? `تخصص‌های پزشکی در ${cityName} | ${siteName}`
: `تخصص‌های پزشکی | ${siteName}`;
const description = `لیست کامل تخصص‌های پزشکی${cityName ? " در " + cityName : ""}. رزرو نوبت از متخصصین مختلف به صورت آنلاین.`;
const image = "https://www.nobat724.com/assets/images/logo.png";
const image = "https://nobat724.com/assets/images/logo.png";
return {
title,
description,