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
@@ -0,0 +1,211 @@
# ممیزی و رفع کامل Technical SEO با محوریت معماری چند-دامنه‌ای
## پروژه
`nobat724_front`
## نقش
تو یک متخصص ارشد Technical SEO با تسلط کامل بر Next.js 15 App Router (SSR/SSG/ISR/RSC/Streaming/PPR)، Metadata API، Core Web Vitals، Structured Data، Crawlability و International/Multi-domain SEO هستی. فقط از قابلیت‌های رسمی و غیرمنسوخ Next.js استفاده کن و آخرین Best Practiceهای Google Search و Vercel را مبنا قرار بده.
## زمینه
سایت نوبت‌دهی پزشکی «نوبت 724» یک deployment واحد Next.js 15 است که **ده‌ها دامنه شهری مستقل** را سرو می‌کند (مثل `arak-nobat.ir`، `yazd-nobat.ir`، ...) به‌علاوه دامنه اصلی `nobat724.com`. تشخیص شهر از روی `host` header انجام می‌شود (`lib/getStateInfo.js` سمت سرور، `context/ProvinceProvider.js` سمت کلاینت). منبع داده دامنه‌ها و متادیتای هر شهر `data/city.json` است (فیلدهای `domain`، `title`، `description`, `keywords`, `site_name`). زبان کل سایت فارسی، RTL، تقویم جلالی است. slug پزشک/کلینیک همان `uuid` است (`/doctor/${uuid}`).
وضعیت فعلی چند تناقض معماری جدی دارد که عملاً SEO دامنه‌های شهری را از کار می‌اندازد (شرح در «وضعیت فعلی»). هدف این پرامپت: ممیزی کامل، تصمیم‌گیری صریح درباره استراتژی چند-دامنه‌ای، و پیاده‌سازی اصلاحات.
## مشکل / هدف
1. تعیین تکلیف استراتژی canonical در معماری چند-دامنه‌ای (مهم‌ترین تصمیم — بقیه کارها به آن وابسته‌اند).
2. رفع مشکلات شناسایی‌شده در sitemap، robots، metadata، JSON-LD، status codeها و rendering.
3. ممیزی سیستماتیک بقیه حوزه‌ها (تصاویر، performance، pagination، accessibility، caching، internal linking) و رفع موارد یافت‌شده.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `app/layout.js` | metadata سطح ریشه، canonical، JSON-LD سازمان/وب‌سایت، viewport، GA |
| `lib/getCanonicalUrl.js` | تولید canonical — **همه دامنه‌های شهری را به دامنه اصلی canonical می‌کند** |
| `components/CanonicalHandler.js` | تزریق canonical سمت کلاینت با DOM (آنتی‌پترن) |
| `middleware.js` | تزریق `x-pathname` برای canonical |
| `app/sitemap.js` + `utils/sitemap.js` | sitemap داینامیک per-domain |
| `app/robots.js` | robots per-domain + گیت `DEV_MODE` |
| `next.config.js` | security headers (CSP/HSTS/...)، images remotePatterns |
| `app/doctor/[slug]/page.js` | صفحه پزشک: `generateMetadata` + JSON-LD Physician |
| `app/clinic/[slug]/page.js` | صفحه کلینیک: مشابه پزشک (MedicalClinic) |
| `app/blog/[slug]/page.js` | صفحه بلاگ (Article) |
| `app/doctors/page.js`، `app/clinics/page.js` | لیست‌های فیلترشونده با searchParams |
| `app/page.js`، `app/specialties/page.js`، `app/blogs/page.js`، `app/about-us/page.js`، `app/contact-us/` | سایر صفحات عمومی |
| `data/city.json`، `data/state.json` | منبع دامنه/متادیتای شهرها |
| `lib/getStateInfo.js`، `lib/rootCity.js` | تشخیص شهر از host |
## وضعیت فعلی (یافته‌های ممیزی اولیه — از کد واقعی)
### ۱. Canonical همه دامنه‌های شهری را نابود می‌کند (Critical)
`lib/getCanonicalUrl.js` هر دامنه‌ای غیر از `nobat724.com` را به دامنه اصلی canonical می‌کند:
```js
// lib/getCanonicalUrl.js
if (normalizedHost === "nobat724.com") {
return null; // No canonical needed for main domain
}
// ...
if (matchedCity || normalizedHost !== "nobat724.com") {
return `${MAIN_DOMAIN}${cleanPathname}`; // MAIN_DOMAIN = "https://nobat724.com"
}
```
یعنی `arak-nobat.ir/doctors` به گوگل می‌گوید «نسخه اصلی من `nobat724.com/doctors` است» — هیچ دامنه شهری هرگز ایندکس/رتبه نمی‌گیرد. ضمناً:
- دامنه اصلی **هیچ self-canonical ندارد** (`return null`).
- شرط `matchedCity || normalizedHost !== "nobat724.com"` همیشه true است (شاخه دوم)، پس `matchedCity` بی‌اثر است.
- canonical فقط در layout ست می‌شود؛ صفحات dynamic (doctor/clinic/blog) که `generateMetadata` خودشان را دارند و `alternates` برنمی‌گردانند، متادیتای layout را override می‌کنند و **عملاً بدون canonical می‌مانند**.
- query stringها در canonical مدیریت نمی‌شوند (`x-pathname` فقط pathname است — صفحات پارامتری `/doctors?specialty=...` بدون canonical).
- `components/CanonicalHandler.js` با `useEffect` تگ canonical به DOM تزریق می‌کند — گوگل ممکن است ببیند یا نبیند؛ آنتی‌پترن و باید حذف شود.
### ۲. Sitemap (High)
```js
// app/sitemap.js
const NOW = new Date(); // module-level — در build/بوت ثابت می‌شود؛ lastmod جعلی
function getCurrentDomain() {
const headersList = headers(); // Next 15: باید await شود
...
}
```
- `lastModified` برای doctor/clinic همیشه `NOW` است (جعلی — گوگل lastmod غیرقابل‌اعتماد را کلاً نادیده می‌گیرد).
- sitemap هر دامنه شهری **همه** پزشکان/کلینیک‌های کشور را لیست می‌کند (fetch بدون فیلتر شهر) — در حالی که صفحات لیست همان دامنه به شهر فیلتر می‌شوند؛ تناقض با استراتژی چند-دامنه‌ای.
- `headers()` بدون `await` (در Next 15 deprecated و در نسخه‌های بعدی می‌شکند).
- fetch با `limit=2000` تک‌صفحه‌ای — بالای ۲۰۰۰ رکورد silent truncation.
- `utils/sitemap.js` دارای `escapeXml` و `createSitemapUrl` است که با Metadata API Route (`app/sitemap.js`) لازم نیستند (Next خودش escape می‌کند) — کد مرده/گمراه‌کننده.
### ۳. Robots (High)
```js
// app/robots.js
return {
rules: { userAgent: '*', allow: '/', disallow: ['/dashboard'] },
sitemap: `${baseUrl}/sitemap.xml`,
};
```
- `/panel`، `/login`، `/login-verify`، `/payment`، `/appointment` (فلوی رزرو کاربر) disallow نشده‌اند.
- `headers()` بدون `await`.
- در حالت `DEV_MODE=TRUE` مسیر sitemap هم حذف می‌شود (درست) ولی صفحات auth-gated فقط با robots بلاک می‌شوند، متای `noindex` per-page ندارند.
### ۴. Metadata سطح ریشه (High)
```js
// app/layout.js
<head>
<meta name="viewport" content="width=device-width,initial-scale=1" />
```
- viewport با تگ دستی به‌جای `export const viewport` (روش رسمی Next 15).
- **`metadataBase` هیچ‌جا تعریف نشده** — در معماری چند-دامنه‌ای باید per-request از host ساخته شود تا URLهای نسبی OG/canonical درست resolve شوند.
- تصاویر OG/Twitter همه صفحات لوگوی `https://www.nobat724.com/assets/images/logo.png` است (با `www.` — در حالی که canonical بدون `www` است؛ ناسازگاری هاست).
- `keywords` meta استفاده شده (بی‌اثر برای گوگل؛ تصمیم بگیر نگه‌داری یا حذف).
- JSON-LD `Organization` و `WebSite` روی **همه دامنه‌ها** به `https://www.nobat724.com` اشاره می‌کند — روی `arak-nobat.ir` داده ساختاریافته متعلق به دامنه دیگر است. `SearchAction` هم فقط به دامنه اصلی.
### ۵. صفحات dynamic — soft 404 و metadata ناقص (High)
```js
// app/doctor/[slug]/page.js
const doctor = await getDoctor(slug);
if (!doctor) return {}; // metadata خالی
```
- وقتی پزشک پیدا نشود `generateMetadata` آبجکت خالی برمی‌گرداند؛ بررسی کن آیا کامپوننت صفحه `notFound()` صدا می‌زند یا با status 200 صفحه نیمه‌خالی رندر می‌شود (**soft 404**). الگوی درست: در صورت null بودن، `notFound()` در body صفحه.
- هیچ‌کدام از صفحات doctor/clinic/blog در `generateMetadata` خود `alternates.canonical` برنمی‌گردانند.
- ISR با `revalidate: 3600` وجود دارد (خوب) — ولی صحت `dynamicParams` و رفتار برای slugهای نامعتبر باید بررسی شود.
### ۶. صفحات لیستی و پارامتری (Medium)
`app/doctors/page.js` با `searchParams` رندر SSR می‌شود (force-dynamic ضمنی). صفحات فیلترشده (`?specialty=...&state=...`) hیچ canonical/robots مشخصی ندارند → ریسک ایندکس بی‌نهایت URL پارامتری duplicate. pagination (اگر با پارامتر `page` است) نه `rel prev/next` دارد نه canonical.
### ۷. Security headers — وضعیت خوب
`next.config.js` HSTS/X-Frame-Options/CSP/Referrer-Policy/Permissions-Policy دارد. فقط `'unsafe-inline' 'unsafe-eval'` در script-src از دید سختگیرانه ضعیف است — گزارش بده ولی تغییر آن اولویت SEO نیست.
## وظایف
### ۰. تصمیم استراتژی چند-دامنه‌ای (پیش‌نیاز همه‌چیز)
دو استراتژی ممکن را مقایسه و **گزینه A را پیاده‌سازی کن** (مگر اینکه در حین کار شواهدی خلافش پیدا کنی؛ در آن صورت قبل از ادامه به کاربر گزارش بده):
- **گزینه A — دامنه‌های شهری first-class:** هر دامنه شهری self-canonical دارد و مستقل ایندکس می‌شود. لازمه‌اش: canonical per-host، sitemap فیلترشده به همان شهر، JSON-LD با URL همان دامنه، محتوای متمایز per-city (که با title/description/فیلتر شهریِ موجود در `city.json` و لیست‌های فیلترشده فراهم است). این با معماری فعلی محصول (فیلتر خودکار شهر در `app/doctors/page.js` بر اساس دامنه) سازگار است.
- **گزینه B — تجمیع روی دامنه اصلی:** وضعیت فعلی canonical، ولی آنگاه وجود دامنه‌های شهری از نظر SEO بی‌معناست.
خروجی این وظیفه: بازنویسی `lib/getCanonicalUrl.js` به یک `buildCanonical(pathname)` که **همیشه** self-canonical روی هاست جاری برمی‌گرداند (بدون www، lowercase، بدون trailing slash، بدون query string به‌جز پارامترهای معنادار whitelisted). حذف کامل `components/CanonicalHandler.js` و همه usageهایش.
### ۱. `metadataBase` و canonical per-page
- در `app/layout.js` از host جاری `metadataBase` بساز:
```js
export async function generateMetadata() {
const headersList = await headers();
const host = (headersList.get("host") || "nobat724.com").toLowerCase().replace(/^www\./, "");
const metadataBase = new URL(`https://${host}`);
// ... alternates: { canonical: pathname } — با metadataBase به URL مطلق resolve می‌شود
}
```
- در `generateMetadata` تک‌تک صفحات (doctor/clinic/blog/doctors/clinics/specialties/blogs/about-us/contact-us/home) `alternates.canonical` نسبی اضافه کن (مثلاً `/doctor/${slug}`).
- صفحات پارامتری: canonical به نسخه بدون پارامتر (یا فقط با پارامترهای whitelisted مثل `specialty`).
- `viewport` را به `export const viewport` منتقل کن و تگ دستی را حذف کن.
### ۲. اصلاح sitemap
- `headers()` را `await` کن.
- sitemap هر دامنه شهری را به همان شهر فیلتر کن (همان پارامترهای `state`/`city` که `app/doctors/page.js` استفاده می‌کند)؛ دامنه اصلی همه را بگیرد. اگر API فیلتر شهر برای doctors/clinics دارد از همان استفاده کن — قرارداد را از `buildDoctorParams` در `helper` و `services/` استخراج کن.
- `lastModified` جعلی (`NOW`) را حذف کن: اگر فیلد تاریخ واقعی (`updated`/`created`) در پاسخ API هست استفاده کن، وگرنه `lastModified` را برای آن entry اصلاً نفرست.
- pagination فراخوانی API (به‌جای `limit=2000` تک‌صفحه) یا حداقل log هشدار در truncation.
- توابع بلااستفاده `escapeXml`/`createSitemapUrl` در `utils/sitemap.js` را حذف یا مستند کن.
### ۳. اصلاح robots
- `await headers()`.
- disallow: `/panel`, `/dashboard`, `/login`, `/login-verify`, `/payment`, `/appointment` (بررسی کن `appointment` صفحه عمومی SEO-دار نباشد — اگر فلوی رزرو شخصی است بلاک شود).
- به صفحات auth-gated (panel/dashboard/login) `robots: { index: false }` per-page اضافه کن (layout آن route group).
### ۴. JSON-LD چند-دامنه‌ای
- `Organization`/`WebSite` در `app/layout.js`: `url` و `SearchAction.target` را از host جاری بساز؛ `name` از `matchedCity.site_name`.
- صفحه doctor: schema `Physician` موجود را validate کن (فیلدهای `address`, `medicalSpecialty`, `image`, `url` مطلق روی دامنه جاری). clinic (`MedicalClinic`) و blog (`Article`) همین‌طور.
- `BreadcrumbList` به صفحات doctor/clinic/blog اضافه کن (خانه → لیست → آیتم) با URLهای دامنه جاری.
- خروجی JSON-LD را با `JSON.stringify(...).replace(/</g, '\\u003c')` یا sanitize موجود در `lib/sanitize.js` در برابر XSS امن کن.
### ۵. Soft 404 و status codes
- در `app/doctor/[slug]/page.js`، `app/clinic/[slug]/page.js`، `app/blog/[slug]/page.js`: اگر fetch نتیجه null داد `notFound()` صدا بزن (از `next/navigation`). `generateMetadata` در حالت null هم `robots: { index: false }` یا metadata حداقلی برگرداند.
- `app/not-found.js` و `app/error.js` را بررسی کن که متای noindex و status صحیح داشته باشند.
### ۶. ممیزی rendering و performance
- برای هر route مشخص کن الان static است یا dynamic (خروجی `npm run build` را بخوان). صفحات محتوایی (about-us, contact-us, specialties, blogs) نباید بی‌دلیل dynamic باشند — دقت کن `headers()` در layout همه‌چیز را dynamic می‌کند؛ این trade-off معماری چند-دامنه‌ای است، مستندش کن و جایی که ممکن است ISR per-route حفظ شود.
- LCP: تصویر hero/بنر صفحه اصلی `priority` داشته باشد؛ استفاده از `next/image` را در کامپوننت‌های اصلی (home, doctor card, doctor page) بررسی کن — هر `<img>` خام را گزارش و به `next/image` با `sizes` مناسب تبدیل کن (ابعاد مشخص → جلوگیری از CLS).
- فونت Vazir: `font-display: swap` موجود است؛ preload فایل woff2 اصلی را در layout اضافه کن.
- اسکریپت GA با `@next/third-parties` لود می‌شود (بهینه است — دست نزن).
### ۷. Pagination و صفحات پارامتری
- در لیست doctors/clinics: لینک‌های صفحه بعد/قبل باید `<a href>` واقعی قابل crawl باشند (نه فقط onClick). بررسی کن `components/doctors/` چطور pagination می‌سازد؛ در صورت client-only بودن، به `<Link>` با href پارامتردار تبدیل کن.
- صفحات با فیلترهای ترکیبی: `robots: { index: false, follow: true }` برای ترکیب‌های بیش از یک فیلتر، تا crawl budget هدر نرود (الگوی رایج سایت‌های listing).
### ۸. گزارش نهایی ممیزی
برای هر مشکل یافت‌شده/رفع‌شده گزارش بده با: عنوان، علت، تأثیر بر SEO، اولویت (Critical/High/Medium/Low)، راه‌حل اعمال‌شده، فایل‌های تغییرکرده. مواردی که عمداً تغییر ندادی (مثل `'unsafe-inline'` در CSP یا `keywords` meta) را با دلیل در بخش «بررسی شد — تغییر لازم نیست/تصمیم محصولی» لیست کن. حوزه‌های چک‌لیست که مشکلی نداشتند (mobile viewport، compress، poweredByHeader، HSTS و…) را هم یک‌خطی تأیید کن.
## نکات مهم
- **همه تغییرات باید نسبت به host جاری relative باشند** — هیچ URL هاردکد `nobat724.com` در metadata/JSON-LD/sitemap باقی نماند مگر آگاهانه (لوگوی fallback OG اشکالی ندارد ولی ترجیحاً از دامنه جاری سرو شود اگر asset موجود است).
- ناسازگاری `www.nobat724.com` (در تصاویر OG و JSON-LD) با `nobat724.com` (در canonical) را یکدست کن — نسخه بدون `www` مبنا.
- Next.js 15: `params`، `searchParams` و `headers()` همگی **باید await شوند**.
- hreflang کاربردی ندارد (همه دامنه‌ها fa-IR هستند) — به‌جایش تمایز محتوایی per-city ملاک است؛ hreflang اضافه نکن.
- `DEV_MODE=TRUE` رفتار noindex سراسری دارد — این مکانیزم را نشکن؛ در همه تغییرات robots/metadata حفظش کن.
- تست‌های موجود (`lib/getStateInfo.test.js`, `lib/multiDomainClient.test.js`, `services.test.js`, vitest) را بعد از تغییرات اجرا کن: `npm run test` (یا `npx vitest run`). برای `getCanonicalUrl` بازنویسی‌شده تست بنویس (هاست شهری، هاست اصلی، www، پورت dev، query string).
- تست دستی چند-دامنه‌ای: dev server با `HOST=yazd-nobat.localhost` بالا می‌آید (`npm run dev`)؛ برای دامنه دیگر `HOST` را موقتاً عوض کن. خروجی `curl -s http://yazd-nobat.localhost:3000 | grep -i canonical` و `/sitemap.xml` و `/robots.txt` را برای حداقل دو هاست مقایسه کن.
- بعد از اتمام، `npm run build` باید بدون خطا پاس شود و در خروجی build بررسی کن هیچ صفحه‌ای ناخواسته از static به dynamic (یا برعکس) جابه‌جا نشده باشد.
- استایل/زبان: همه رشته‌های جدید فارسی، RTL؛ کد مطابق الگوهای موجود پروژه (jsx، بدون TypeScript).
+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,
-36
View File
@@ -1,36 +0,0 @@
'use client';
import { useEffect } from 'react';
import { getCanonicalUrlClient } from '@/lib/getCanonicalUrl';
/**
* Client-side canonical URL handler
* Use this component in pages that need dynamic canonical URLs
* that can't be determined at build time or in generateMetadata
*/
export default function CanonicalHandler() {
useEffect(() => {
// Only run on client side
if (typeof window === 'undefined') return;
const canonicalUrl = getCanonicalUrlClient();
if (canonicalUrl) {
// Check if canonical link already exists
let canonicalLink = document.querySelector('link[rel="canonical"]');
if (canonicalLink) {
// Update existing canonical link
canonicalLink.href = canonicalUrl;
} else {
// Create new canonical link
canonicalLink = document.createElement('link');
canonicalLink.rel = 'canonical';
canonicalLink.href = canonicalUrl;
document.head.appendChild(canonicalLink);
}
}
}, []);
return null; // This component doesn't render anything
}
+39 -69
View File
@@ -1,80 +1,50 @@
import { headers } from "next/headers";
import citiesData from "@/data/city.json";
const MAIN_DOMAIN = "https://nobat724.com";
// استراتژی چند-دامنه‌ای: هر دامنه‌ی شهری یک property مستقل و first-class است.
// هر صفحه self-canonical روی هاست جاری می‌گیرد؛ هرگز به دامنه‌ی اصلی canonical نمی‌دهیم.
const DEFAULT_HOST = "nobat724.com";
export function normalizeHost(rawHost) {
return (rawHost || DEFAULT_HOST).toLowerCase().replace(/^www\./, "");
}
export function buildCanonicalPath(pathname) {
let path = (pathname || "/").split("?")[0].split("#")[0];
if (!path.startsWith("/")) path = `/${path}`;
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
return path;
}
export function buildCanonicalUrl(host, pathname) {
const normalizedHost = normalizeHost(host);
const hostname = normalizedHost.replace(/:\d+$/, "");
const protocol =
hostname.endsWith("localhost") || hostname === "127.0.0.1" ? "http" : "https";
return `${protocol}://${normalizedHost}${buildCanonicalPath(pathname)}`;
}
// origin هاست جاری (بدون trailing slash) — برای ساخت URLهای مطلق در JSON-LD و متادیتا
export async function getRequestOrigin() {
try {
const headersList = await headers();
return buildCanonicalUrl(headersList.get("host"), "/").replace(/\/$/, "");
} catch {
return "https://nobat724.com";
}
}
/**
* Determines if the current domain should have a canonical tag
* and returns the canonical URL if needed
*/
export async function getCanonicalUrl() {
try {
const headersList = await headers();
const host = headersList.get("host") || "";
const pathname = headersList.get("x-pathname") || "/";
// Normalize host (remove www and convert to lowercase)
const normalizedHost = host.toLowerCase().replace(/^www\./, "");
// Check if current domain is the main domain
if (normalizedHost === "nobat724.com") {
return null; // No canonical needed for main domain
}
// Check if this is a subdomain/different domain from our city data
const subdomain = normalizedHost.split(".")[0];
const matchedCity = citiesData.find((city) => {
const cityDomain = city.domain.toLowerCase();
return cityDomain.includes(subdomain) || cityDomain === normalizedHost;
});
// If we found a matching city or any non-main domain, canonicalize to main domain
if (matchedCity || normalizedHost !== "nobat724.com") {
// Ensure pathname starts with /
const cleanPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
return `${MAIN_DOMAIN}${cleanPathname}`;
}
return null;
const host = headersList.get("host");
const pathname = headersList.get("x-pathname");
// مسیرهای خارج از matcher میدل‌ور (panel/dashboard/login) این هدر را ندارند —
// آن‌ها noindex هستند و نباید canonical جعلی به ریشه بگیرند.
if (!pathname) return null;
return buildCanonicalUrl(host, pathname);
} catch (error) {
console.error("Error generating canonical URL:", error);
return null;
}
}
/**
* Client-side version for dynamic routes or client components
*/
export function getCanonicalUrlClient() {
if (typeof window === "undefined") return null;
try {
const host = window.location.host;
const pathname = window.location.pathname;
// Normalize host (remove www and convert to lowercase)
const normalizedHost = host.toLowerCase().replace(/^www\./, "");
// Check if current domain is the main domain
if (normalizedHost === "nobat724.com") {
return null; // No canonical needed for main domain
}
// Check if this is a subdomain/different domain from our city data
const subdomain = normalizedHost.split(".")[0];
const matchedCity = citiesData.find((city) => {
const cityDomain = city.domain.toLowerCase();
return cityDomain.includes(subdomain) || cityDomain === normalizedHost;
});
// If we found a matching city or any non-main domain, canonicalize to main domain
if (matchedCity || normalizedHost !== "nobat724.com") {
return `${MAIN_DOMAIN}${pathname}`;
}
return null;
} catch (error) {
console.error("Error generating client canonical URL:", error);
return null;
}
}
+29 -13
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { getStateInfoClient } from '@/lib/getStateInfoClient';
import { getCanonicalUrlClient } from '@/lib/getCanonicalUrl';
import { buildCanonicalUrl, buildCanonicalPath, normalizeHost } from '@/lib/getCanonicalUrl';
function setLocation(loc) {
Object.defineProperty(window, 'location', { value: loc, writable: true, configurable: true });
@@ -21,22 +21,38 @@ describe('getStateInfoClient (window)', () => {
});
});
describe('getCanonicalUrlClient (window)', () => {
beforeEach(() => {
setLocation({ host: 'arak-nobat.ir', pathname: '/doctors' });
describe('buildCanonicalUrl (self-canonical per-host)', () => {
it('دامنه‌ی شهری → self-canonical روی همان دامنه', () => {
expect(buildCanonicalUrl('arak-nobat.ir', '/doctors')).toBe('https://arak-nobat.ir/doctors');
});
it('subdomain → canonical به دامنه‌ی اصلی', () => {
expect(getCanonicalUrlClient()).toBe('https://nobat724.com/doctors');
it('دامنه‌ی اصلی → self-canonical روی دامنه‌ی اصلی', () => {
expect(buildCanonicalUrl('nobat724.com', '/doctors')).toBe('https://nobat724.com/doctors');
});
it('دامنه‌ی اصلی → null', () => {
setLocation({ host: 'nobat724.com', pathname: '/doctors' });
expect(getCanonicalUrlClient()).toBeNull();
it('www و حروف بزرگ نرمال می‌شوند', () => {
expect(buildCanonicalUrl('WWW.Nobat724.com', '/x')).toBe('https://nobat724.com/x');
});
it('www و حروف بزرگ نرمال می‌شوند (دامنه‌ی اصلی) → null', () => {
setLocation({ host: 'WWW.Nobat724.com', pathname: '/x' });
expect(getCanonicalUrlClient()).toBeNull();
it('هاست dev با پورت → http و حفظ پورت', () => {
expect(buildCanonicalUrl('yazd-nobat.localhost:3000', '/doctors')).toBe(
'http://yazd-nobat.localhost:3000/doctors'
);
});
it('query string و trailing slash حذف می‌شوند', () => {
expect(buildCanonicalUrl('arak-nobat.ir', '/doctors/?specialty=قلب')).toBe(
'https://arak-nobat.ir/doctors'
);
});
it('ریشه دست‌نخورده می‌ماند', () => {
expect(buildCanonicalUrl('arak-nobat.ir', '/')).toBe('https://arak-nobat.ir/');
expect(buildCanonicalPath('/')).toBe('/');
});
it('هاست خالی → دامنه‌ی پیش‌فرض', () => {
expect(normalizeHost('')).toBe('nobat724.com');
expect(buildCanonicalUrl(null, '/x')).toBe('https://nobat724.com/x');
});
});
+5
View File
@@ -14,6 +14,11 @@ export function sanitizeHtml(html) {
});
}
// خروجی امن برای <script type="application/ld+json"> — جلوگیری از بستن تگ با داده‌ی کاربر
export function safeJsonLd(obj) {
return JSON.stringify(obj).replace(/</g, "\\u003c");
}
export function safeJsonParse(str, fallback = null) {
if (!str) return fallback;
try {
+3 -55
View File
@@ -1,57 +1,5 @@
// Utility functions for sitemap generation
export const DOMAIN_CONFIG = {
MAIN_DOMAIN: 'nobat724.com',
MAX_SPECIALTY_COMBINATIONS: 20, // Limit specialty combinations to prevent too many URLs
MAIN_CITIES: ['108', '104', '111', '117', '113'], // Tehran, Isfahan, Mashhad, Shiraz, Ahvaz
};
export function isMainDomain(domain) {
return domain === DOMAIN_CONFIG.MAIN_DOMAIN || domain.includes('localhost') || domain.includes('127.0.0.1');
}
export function encodeUrlParam(param) {
return encodeURIComponent(param || '');
}
export function createSitemapUrl(baseUrl, path, params = {}) {
let url = `${baseUrl}${path}`;
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value) {
searchParams.append(key, value);
}
});
const queryString = searchParams.toString();
const fullUrl = queryString ? `${url}?${queryString}` : url;
// Escape XML entities
return escapeXml(fullUrl);
}
export function escapeXml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
export function createSitemapEntry(url, priority = 0.8, changeFrequency = 'weekly') {
return {
url,
lastModified: new Date(),
changeFrequency,
priority,
};
}
export function limitArray(array, limit) {
return limit > 0 ? array.slice(0, limit) : array;
}
import { buildCanonicalUrl } from '@/lib/getCanonicalUrl';
export function getBaseUrl(domain) {
return `https://${domain}`;
}
return buildCanonicalUrl(domain, '/').replace(/\/$/, '');
}