From 002f388ac8f3cf005f671422674ae624eacb538c Mon Sep 17 00:00:00 2001
From: hamed <15238-genius.ha@users.noreply.drupalcode.org>
Date: Sun, 9 Aug 2026 07:14:15 +0330
Subject: [PATCH] feat: Enhance metadata generation for About Us and Contact Us
pages, implement legacy slug resolution for blog posts, and add city-specific
intros
---
app/about-us/page.js | 15 +++-
app/blog/[slug]/page.js | 31 +++++++-
app/contact-us/page.js | 25 +++++++
components/aboutUs/index.js | 9 ++-
components/contactUs/call/Call.js | 7 +-
components/contactUs/index.js | 15 +++-
data/blogLegacySlugs.json | 6 ++
lib/entityQuality.js | 15 ++--
lib/entityQuality.test.js | 34 +++++++++
lib/getCanonicalUrl.js | 17 +++--
lib/staticPageIntro.js | 70 ++++++++++++++++++
lib/staticPageIntro.test.js | 118 ++++++++++++++++++++++++++++++
12 files changed, 334 insertions(+), 28 deletions(-)
create mode 100644 data/blogLegacySlugs.json
create mode 100644 lib/entityQuality.test.js
create mode 100644 lib/staticPageIntro.js
create mode 100644 lib/staticPageIntro.test.js
diff --git a/app/about-us/page.js b/app/about-us/page.js
index 1cda382..48645b9 100644
--- a/app/about-us/page.js
+++ b/app/about-us/page.js
@@ -3,10 +3,19 @@ import Layout from "@/components/layout/StLayout";
import { getStateInfo } from "@/lib/getStateInfo";
export async function generateMetadata() {
- const { matchedCity } = await getStateInfo();
+ const { matchedCity, matchedState, isRoot } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724";
- const title = `درباره ما | ${siteName}`;
- const description = `آشنایی با ${siteName}، سیستم آنلاین نوبتدهی پزشکی. هدف ما ارائه خدمات سریع و کارآمد رزرو نوبت پزشکی برای همه مردم است.`;
+ const cityName = isRoot ? null : matchedCity?.name;
+ const provinceName = isRoot ? null : matchedState?.name;
+
+ // صفحه self-canonical است؛ عنوان و توضیح باید شهری باشند وگرنه ۳۵ دامنه یک
+ // متادیتا اعلام میکنند و همان duplicate با شکل تازه برمیگردد.
+ const title = cityName
+ ? `درباره ما | نوبتدهی آنلاین پزشکان ${cityName} | ${siteName}`
+ : `درباره ما | ${siteName}`;
+ const description = cityName
+ ? `آشنایی با ${siteName}، سامانه رزرو آنلاین نوبت پزشکان ${cityName}${provinceName ? ` و ${provinceName}` : ""}. جستوجوی پزشک بر اساس تخصص و ثبت نوبت بدون تماس تلفنی.`
+ : `آشنایی با ${siteName}، سیستم آنلاین نوبتدهی پزشکی. هدف ما ارائه خدمات سریع و کارآمد رزرو نوبت پزشکی برای همه مردم است.`;
const image = "/assets/images/og-image.png";
return {
title,
diff --git a/app/blog/[slug]/page.js b/app/blog/[slug]/page.js
index 7e4ba4e..aa088cb 100644
--- a/app/blog/[slug]/page.js
+++ b/app/blog/[slug]/page.js
@@ -1,7 +1,7 @@
import { cache } from "react";
import BlogPage from "@/components/blog";
import Layout from "@/components/layout/StLayout";
-import { notFound } from "next/navigation";
+import { notFound, permanentRedirect } from "next/navigation";
import { fetchReq } from "@/lib/req";
import { getStateInfo } from "@/lib/getStateInfo";
import { getBlogCanonicalOrigin } from "@/lib/getCanonicalUrl";
@@ -13,6 +13,7 @@ import {
import { safeJsonLd } from "@/lib/sanitize";
import { decodeRouteParam } from "@/lib/routeParams";
import { normalizeBlog, blogCover } from "@/helper";
+import legacySlugs from "@/data/blogLegacySlugs.json";
const API_URL = process.env.NEXT_PUBLIC_API_URL;
@@ -39,13 +40,31 @@ async function getScopedBlog(slug) {
return getBlog(slug, domainScopeCityId({ matchedCity, isRoot }));
}
+/**
+ * پست را با هر شناسهای که روزی به آن اشاره داشته پیدا میکند.
+ *
+ * بکاند خودش slug و uuid و topic_slug و پسوند ۸ کاراکتریِ uuid را resolve میکند.
+ * چیزی که آنجا قابل resolve نیست، اسلاگهای لاتینِ نسل قبلاند: هیچ ردی در دیتابیس
+ * ندارند چون هنگام تغییر عنوان بازنویسی شدند. Search Console همانها را روی
+ * `behbahan-nobat.ir` ۴۰۴ گزارش کرد، پس نگاشتشان در data/blogLegacySlugs.json
+ * دستی نگه داشته میشود — مقدارِ نگاشت پیشوند uuid است، نه اسلاگ مقصد، تا فیلتر
+ * شهریِ همان درخواست دوباره اعمال شود و پستِ شهر دیگر ریدایرکتِ ۴۰۴ نسازد.
+ */
+async function resolveScopedBlog(slug) {
+ const blog = await getScopedBlog(slug);
+ if (blog) return blog;
+
+ const legacyUuidPrefix = legacySlugs[slug];
+ return legacyUuidPrefix ? getScopedBlog(legacyUuidPrefix) : null;
+}
+
export async function generateMetadata({ params }) {
const { slug: rawSlug } = await params;
const slug = decodeRouteParam(rawSlug);
const { matchedCity } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724";
- const blog = await getScopedBlog(slug);
+ const blog = await resolveScopedBlog(slug);
if (!blog) notFound();
try {
@@ -123,9 +142,15 @@ async function Blog({ params }) {
const slug = decodeRouteParam(rawSlug);
const slugPath = encodeURIComponent(slug);
- const blog = normalizeBlog(await getScopedBlog(slug));
+ const blog = normalizeBlog(await resolveScopedBlog(slug));
if (!blog) notFound();
+ // اسلاگ کهنه (عنوان عوض شده یا نسل لاتین) به آدرس قطعی منتقل میشود؛ دو URL برای
+ // یک محتوا باقی نمیماند و لینکهای قدیمیِ ایندکسشده اعتبارشان را میبرند.
+ if (blog.slug && blog.slug !== slug) {
+ permanentRedirect(`/blog/${encodeURIComponent(blog.slug)}`);
+ }
+
const blogCityId = extractEntityCityId(blog);
const blogCity = findCityById(blogCityId);
const origin = getBlogCanonicalOrigin(blogCityId);
diff --git a/app/contact-us/page.js b/app/contact-us/page.js
index d63877b..0e65b5c 100644
--- a/app/contact-us/page.js
+++ b/app/contact-us/page.js
@@ -1,5 +1,30 @@
import ContactUsPage from "@/components/contactUs";
import Layout from "@/components/layout/StLayout";
+import { getStateInfo } from "@/lib/getStateInfo";
+
+export async function generateMetadata() {
+ const { matchedCity, matchedState, isRoot } = await getStateInfo();
+ const siteName = matchedCity?.site_name || "نوبت 724";
+ const cityName = isRoot ? null : matchedCity?.name;
+ const provinceName = isRoot ? null : matchedState?.name;
+
+ // صفحه self-canonical است؛ عنوان و توضیح هم باید شهری باشند وگرنه ۳۵ دامنه
+ // یک متادیتا اعلام میکنند.
+ const title = cityName
+ ? `تماس با ما | نوبتدهی آنلاین ${cityName} | ${siteName}`
+ : `تماس با ما | ${siteName}`;
+ const description = cityName
+ ? `راههای ارتباط با ${siteName}: تلفن، واتساپ و ایمیل. پشتیبانی رزرو و لغو نوبت پزشکان ${cityName}${provinceName ? ` و ${provinceName}` : ""}.`
+ : `راههای ارتباط با ${siteName}: تلفن، واتساپ و ایمیل. پشتیبانی رزرو و لغو نوبت آنلاین پزشکان.`;
+ const image = "/assets/images/og-image.png";
+
+ return {
+ title,
+ description,
+ openGraph: { title, description, images: [image] },
+ twitter: { card: "summary_large_image", title, description, images: [image] },
+ };
+}
function ContactUs() {
return (
diff --git a/components/aboutUs/index.js b/components/aboutUs/index.js
index 48ecbfe..9711470 100644
--- a/components/aboutUs/index.js
+++ b/components/aboutUs/index.js
@@ -1,11 +1,17 @@
import { getStateInfo } from "@/lib/getStateInfo";
+import { buildAboutIntro } from "@/lib/staticPageIntro";
import Head from "./Head";
import Services from "./Services";
async function AboutUsPage() {
- const { matchedCity } = await getStateInfo();
+ const { matchedCity, matchedState, isRoot } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت ۷۲۴";
const slogan = matchedCity?.slogan;
+ // این صفحه self-canonical است؛ بدون متن شهری، ۳۵ دامنه یک محتوا اعلام میکردند.
+ // دامنهٔ ریشه شهر نیست، پس متن شهری نمیگیرد.
+ const cityIntro = isRoot
+ ? ""
+ : buildAboutIntro(matchedCity?.name, matchedState?.name, siteName, slogan);
return (
+ {cityIntro &&
{cityIntro}
}
{siteName} یک سامانهی آنلاین نوبتدهی پزشکی است که با هدف سادهتر کردن
دسترسی مردم به خدمات سلامت طراحی شده است. ما تلاش میکنیم تجربهی رزرو
diff --git a/components/contactUs/call/Call.js b/components/contactUs/call/Call.js
index 178f149..3f60122 100644
--- a/components/contactUs/call/Call.js
+++ b/components/contactUs/call/Call.js
@@ -1,6 +1,6 @@
import Links from "./Links";
-function Call({ matchedCity, isDefault }) {
+function Call({ matchedCity, isDefault, cityIntro }) {
return (
{matchedCity?.footer_description || "برای ارتباط با ما از راه های زیر استفاده کنید"}
+ {cityIntro && (
+
+ {cityIntro}
+
+ )}
);
diff --git a/components/contactUs/index.js b/components/contactUs/index.js
index ed444da..6a93acb 100644
--- a/components/contactUs/index.js
+++ b/components/contactUs/index.js
@@ -1,13 +1,24 @@
import { getStateInfo } from "@/lib/getStateInfo";
+import { buildContactIntro } from "@/lib/staticPageIntro";
import Call from "./call/Call";
import Connect from "./Connect";
async function ContactUsPage() {
- const { matchedCity, isRoot } = await getStateInfo();
+ const { matchedCity, matchedState, isRoot } = await getStateInfo();
+ // مثل «درباره ما»: این صفحه هم self-canonical شد و بدون متن شهری، تلفن و ایمیل
+ // یکسانِ ۳۵ دامنه دوباره duplicate میساخت.
+ const cityIntro = isRoot
+ ? ""
+ : buildContactIntro(
+ matchedCity?.name,
+ matchedState?.name,
+ matchedCity?.site_name,
+ matchedCity?.slogan
+ );
return (
-
+
{isRoot && }
);
diff --git a/data/blogLegacySlugs.json b/data/blogLegacySlugs.json
new file mode 100644
index 0000000..fa99406
--- /dev/null
+++ b/data/blogLegacySlugs.json
@@ -0,0 +1,6 @@
+{
+ "alamat-tahooe-mokarrar-dar-bimaran-sartani": "c097ddec",
+ "eyepressure-glasses-specialized-optometry-assessment": "e2cfc318",
+ "warning-signs-sleep-elderly": "6a67ded7",
+ "tashkhis-sari-avarzhe-daru-qatk-masraf": "44424a3c"
+}
diff --git a/lib/entityQuality.js b/lib/entityQuality.js
index 4c4817f..e6a69a4 100644
--- a/lib/entityQuality.js
+++ b/lib/entityQuality.js
@@ -23,21 +23,16 @@ export function isThinDoctor(doctor) {
return !hasSpecialty && !hasLocation;
}
-/**
- * پروفایل بدون مالک (claim نشده). دادهاش از منبع بیرونی آمده و صاحبش تأییدش نکرده،
- * پس محتوای قابل اتکا برای ایندکس نیست. هم پاسخ لیست و هم پاسخ جزئیات `owner_status`
- * دارند؛ نبودِ فیلد هم «مالک ندارد» تفسیر میشود تا هیچوقت بهطور خاموش ایندکس نشود.
- */
-export function isUnclaimedDoctor(doctor) {
- return doctor?.owner_status !== "claimed";
-}
-
/**
* منبع واحدِ ایندکسپذیری پزشک: هم صفحهٔ پزشک (robots) و هم sitemap از این استفاده
* میکنند تا واگرا نشوند — چیزی که noindex است نباید در sitemap باشد.
+ *
+ * سنجه فقط محتواست، نه مالکیت. شرط `owner_status !== "claimed"` برداشته شد چون
+ * ۸۰ صفحهٔ یاسوج و ۱۲ صفحهٔ بهبهان را از ایندکس بیرون نگه میداشت — پروفایلهایی که
+ * نام و تخصص و نشانی داشتند. claim یک وضعیت اداری است، نه سنجهٔ کیفیت محتوا.
*/
export function isNoindexDoctor(doctor) {
- return isThinDoctor(doctor) || isUnclaimedDoctor(doctor);
+ return isThinDoctor(doctor);
}
/**
diff --git a/lib/entityQuality.test.js b/lib/entityQuality.test.js
new file mode 100644
index 0000000..5619b17
--- /dev/null
+++ b/lib/entityQuality.test.js
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { isNoindexDoctor, isThinDoctor } from "@/lib/entityQuality";
+
+const DOCTOR = {
+ uuid: "1a55deaa-6c43-440d-b126-5870521ebc26",
+ name: "دکتر رضایی",
+ specialties: [{ name: "قلب و عروق" }],
+ address: [{ address: "یاسوج، خیابان اصلی" }],
+};
+
+describe("ایندکسپذیری پزشک", () => {
+ it("پروفایل claim نشدهٔ دارای محتوا ایندکس میشود", () => {
+ // پیش از این، همین حالت ۸۰ صفحهٔ یاسوج را noindex میکرد.
+ expect(isNoindexDoctor({ ...DOCTOR, owner_status: "unclaimed" })).toBe(false);
+ expect(isNoindexDoctor({ ...DOCTOR, owner_status: undefined })).toBe(false);
+ });
+
+ it("پروفایل claim شده هم ایندکس میشود", () => {
+ expect(isNoindexDoctor({ ...DOCTOR, owner_status: "claimed" })).toBe(false);
+ });
+
+ it("پروفایل بیمحتوا همچنان noindex میماند", () => {
+ expect(
+ isNoindexDoctor({ uuid: DOCTOR.uuid, name: "دکتر رضایی" })
+ ).toBe(true);
+ expect(isNoindexDoctor({ ...DOCTOR, name: "09121234567" })).toBe(true);
+ expect(isNoindexDoctor({ ...DOCTOR, uuid: undefined })).toBe(true);
+ });
+
+ it("تخصص یا نشانی، هرکدام بهتنهایی کافی است", () => {
+ expect(isThinDoctor({ ...DOCTOR, address: [] })).toBe(false);
+ expect(isThinDoctor({ ...DOCTOR, specialties: [] })).toBe(false);
+ });
+});
diff --git a/lib/getCanonicalUrl.js b/lib/getCanonicalUrl.js
index 2948ce8..60d81f9 100644
--- a/lib/getCanonicalUrl.js
+++ b/lib/getCanonicalUrl.js
@@ -6,19 +6,20 @@ import { findDomainByCityId } from "@/lib/domainHelpers";
// → self-canonical روی همان دامنهٔ شهری (هر شهر محتوای خودش را دارد)
// C1-b موجودیت : /doctor/[uuid]، /clinic/[uuid]، /blog/[slug]
// → دامنهٔ شهرِ همان موجودیت (getEntityCanonical)
-// C1-c استاتیک : /about-us، /contact-us، … → دامنهٔ اصلی
-// (محتوا روی همهٔ دامنهها یکسان است — تجمیع عمدی اعتبار)
+// C1-c استاتیک : /terms، /privacy → دامنهٔ اصلی
+// (متن حقوقی روی همهٔ دامنهها کلمهبهکلمه یکسان است)
+//
+// /about-us و /contact-us از C1-c بیرون آمدند: هر شهر آنجا داده و متن خودش را دارد
+// (نام سایت، تلفن، نشانی، شبکههای اجتماعی، متن شهری)، پس self-canonical میشوند.
+// پیش از این هر دو به دامنهٔ اصلی canonical میشدند و Search Console آنها را
+// «Alternate page with proper canonical tag» گزارش میکرد؛ یعنی روی هیچ دامنهٔ شهری
+// ایندکس نمیشدند.
const DEFAULT_HOST = "nobat724.com";
const MAIN_ORIGIN = "https://nobat724.com";
// C1-c — صفحات با محتوای یکسان روی همهٔ دامنهها
-const STATIC_SHARED_PAGES = new Set([
- "/about-us",
- "/contact-us",
- "/terms",
- "/privacy",
-]);
+const STATIC_SHARED_PAGES = new Set(["/terms", "/privacy"]);
// تنها queryهایی که صفحه را از نظر محتوایی متمایز میکنند و باید در canonical بمانند.
// بقیه (فیلتر چندگانه، sort، name) حذف میشوند تا duplicate نسازند.
diff --git a/lib/staticPageIntro.js b/lib/staticPageIntro.js
new file mode 100644
index 0000000..e6c3bf1
--- /dev/null
+++ b/lib/staticPageIntro.js
@@ -0,0 +1,70 @@
+// متن شهریِ صفحات «درباره ما» و «تماس با ما».
+//
+// این دو صفحه تا پیش از این به دامنهٔ اصلی canonical میشدند، چون متنشان روی همهٔ
+// دامنهها یکی بود. حالا self-canonicalاند و باید واقعاً متفاوت باشند، وگرنه همان
+// duplicate با شکل تازه برمیگردد.
+//
+// دادهای که واقعاً بین شهرها فرق میکند: نام شهر، نام استان، و شعار شهر.
+// تلفن و ایمیل روی هر ۳۵ شهر یکسان است، پس سیگنال تمایز نیستند.
+
+function hash(value) {
+ let out = 0;
+ for (let i = 0; i < value.length; i++) out = (out * 31 + value.charCodeAt(i)) >>> 0;
+ return out;
+}
+
+const pick = (variants, seed) => variants[(seed >>> 0) % variants.length];
+
+const ABOUT_INTROS = (city, province, brand) => [
+ `${brand} سرویس نوبتدهی آنلاین شهر ${city} است. پزشکان عمومی و متخصصی که در ${city} پذیرش دارند اینجا پروفایل دارند و میتوانید بدون تماس تلفنی نوبتشان را بگیرید. پوشش ما ${province} است و فهرست پزشکان همین استان بهمرور کاملتر میشود.`,
+ `اگر ساکن ${city} هستید، ${brand} کوتاهترین مسیر تا نوبت پزشک است. جستوجو بر اساس تخصص یا نام پزشک انجام میشود و نتیجه فقط پزشکان همین شهر و اطراف آن در ${province} را نشان میدهد، نه فهرستی سراسری که به کارتان نمیآید.`,
+ `${brand} بخشی از شبکهای است که برای هر شهر یک سایت مستقل دارد. این نسخه مخصوص ${city} است: پزشکان، مراکز درمانی و برنامهٔ نوبتدهی همه از ${city} و ${province} میآیند. همین باعث میشود نتیجهٔ جستوجو کوتاه و مرتبط بماند.`,
+ `هدف ${brand} این است که مراجعهٔ حضوری برای گرفتن نوبت در ${city} حذف شود. برنامهٔ کاری پزشک، بازههای خالی و هزینهٔ خدمت پیش از تأیید نهایی نمایش داده میشود. دامنهٔ کاری ما ${province} است و مراکز درمانی همین استان به سامانه اضافه میشوند.`,
+ `${brand} برای مردم ${city} ساخته شده است. بهجای گشتن میان شمارههای پراکنده، اینجا پزشک را بر اساس تخصص پیدا میکنید، نوبت آزادش را میبینید و همان لحظه رزرو میکنید. پزشکان ${province} بهتدریج به همین فهرست اضافه میشوند.`,
+ `این صفحه دربارهٔ ${brand} است، سامانهٔ نوبتدهی آنلاین ${city}. کار ما ساده است: نشان دادن اینکه کدام پزشک ${city} چه زمانی نوبت آزاد دارد و ثبت نوبت بدون تماس تلفنی. تمرکز جغرافیایی ما روی ${province} است.`,
+];
+
+const CONTACT_INTROS = (city, province, brand) => [
+ `برای ارتباط با ${brand} از راههای زیر استفاده کنید. پاسخگویی مربوط به کاربران ${city} و ${province} از همین کانالها انجام میشود: پیگیری نوبت، لغو نوبت و مشکلات ورود به حساب.`,
+ `اگر در ${city} با نوبتی که رزرو کردهاید مشکلی داشتید، از همین صفحه با ${brand} تماس بگیرید. برای هماهنگی مستقیم با مطب، شمارهٔ خود پزشک روی صفحهٔ پروفایلش در ${province} آمده است.`,
+ `${brand} پشتیبانی کاربران ${city} را از راههای زیر انجام میدهد. برای درخواست افزودن مطب یا مرکز درمانی در ${province} هم از همین کانالها میتوانید اقدام کنید.`,
+ `اینها راههای تماس با ${brand} است. سؤالهای مربوط به رزرو، لغو و بازگشت وجه کاربران ${city} از همینجا پیگیری میشود و برای پزشکان ${province} هم مسیر جداگانهای برای ثبتنام وجود دارد.`,
+ `ارتباط با ${brand} برای کاربران ${city} از طریق تلفن، واتساپ و ایمیل ممکن است. اگر پزشک یا مسئول مرکز درمانی در ${province} هستید و میخواهید به سامانه اضافه شوید، از همین صفحه هماهنگ کنید.`,
+ `اگر سؤالی دربارهٔ نوبتدهی آنلاین در ${city} دارید، تیم ${brand} از راههای زیر در دسترس است. برای موضوعهای مربوط به خودِ مطب — تغییر ساعت ویزیت یا آدرس — تماس با مطب در ${province} سریعتر جواب میدهد.`,
+];
+
+/**
+ * جملهٔ پایانی که به متن میچسبد.
+ *
+ * با ۶ variant و ۳۵ شهر، جفتهای همvariant اجتنابناپذیرند — سنجیده شد: بهبهان و
+ * یاسوج روی هر دو صفحه یک variant گرفتند و تفاوتشان فقط جایگذاری نام شهر بود.
+ * شعار شهر این را میشکند چون در city.json برای هر ۳۵ شهر یکتاست.
+ */
+const sloganSentence = (slogan) =>
+ slogan ? ` شعار ما همین است: ${slogan}.` : "";
+
+/**
+ * seed از نام شهر میآید تا متن هر شهر ثابت بماند ولی بین شهرها واگرا شود.
+ * دو مجموعه seed متفاوت میگیرند، وگرنه یک شهر در هر دو صفحه ساختار یکسان میگرفت.
+ */
+export function buildAboutIntro(cityName, provinceName, siteName, slogan = "") {
+ if (!cityName) return "";
+ const province = provinceName || cityName;
+ const brand = siteName || `${cityName} نوبت`;
+
+ return (
+ pick(ABOUT_INTROS(cityName, province, brand), hash(cityName) >>> 3) +
+ sloganSentence(slogan)
+ );
+}
+
+export function buildContactIntro(cityName, provinceName, siteName, slogan = "") {
+ if (!cityName) return "";
+ const province = provinceName || cityName;
+ const brand = siteName || `${cityName} نوبت`;
+
+ return (
+ pick(CONTACT_INTROS(cityName, province, brand), hash(cityName) >>> 7) +
+ sloganSentence(slogan)
+ );
+}
diff --git a/lib/staticPageIntro.test.js b/lib/staticPageIntro.test.js
new file mode 100644
index 0000000..82bf00f
--- /dev/null
+++ b/lib/staticPageIntro.test.js
@@ -0,0 +1,118 @@
+import { describe, expect, it } from "vitest";
+import cityData from "@/data/city.json";
+import stateData from "@/data/state.json";
+import { buildAboutIntro, buildContactIntro } from "@/lib/staticPageIntro";
+import { buildCanonicalUrl, buildMainCanonicalUrl } from "@/lib/getCanonicalUrl";
+
+const CITIES = ["بهبهان", "یاسوج", "یزد", "تبریز", "اصفهان"];
+
+describe("متن شهری صفحات استاتیک", () => {
+ it("دو شهر متن یکسان نمیگیرند", () => {
+ expect(buildAboutIntro("بهبهان", "خوزستان", "بهبهان نوبت")).not.toBe(
+ buildAboutIntro("یاسوج", "کهگیلویه و بویراحمد", "یاسوج نوبت")
+ );
+ });
+
+ it("یک شهر در دو فراخوانی همان متن را میدهد", () => {
+ expect(buildAboutIntro("بهبهان", "خوزستان", "بهبهان نوبت")).toBe(
+ buildAboutIntro("بهبهان", "خوزستان", "بهبهان نوبت")
+ );
+ });
+
+ it("درباره ما و تماس با ما برای یک شهر متن یکسان نمیگیرند", () => {
+ // seed هر دو از نام شهر میآید؛ اگر جابهجایی بیت نبود، یک ساختار میگرفتند.
+ const about = buildAboutIntro("یزد", "یزد", "یزد نوبت");
+ const contact = buildContactIntro("یزد", "یزد", "یزد نوبت");
+
+ expect(about).not.toBe(contact);
+ });
+
+ it("نام شهر و استان و برند در متن میآیند", () => {
+ const text = buildAboutIntro("بهبهان", "خوزستان", "بهبهان نوبت");
+
+ expect(text).toContain("بهبهان");
+ expect(text).toContain("خوزستان");
+ });
+
+ it("شهر بدون نام، متن خالی میدهد و صفحه را نمیشکند", () => {
+ expect(buildAboutIntro(undefined, "خوزستان", "نوبت 724")).toBe("");
+ expect(buildContactIntro(null, null, null)).toBe("");
+ });
+
+ it("استان نامشخص جای خالی و undefined در متن نمیگذارد", () => {
+ const text = buildContactIntro("شهر تازه", undefined, undefined);
+
+ expect(text).not.toContain("undefined");
+ expect(text).toContain("شهر تازه");
+ });
+
+ it("متنها روی شهرهای واقعی به یک قالب واحد نمیچسبند", () => {
+ const texts = CITIES.map((c) => buildAboutIntro(c, "استان", `${c} نوبت`));
+
+ expect(new Set(texts).size).toBeGreaterThan(1);
+ });
+
+ it("روی همهٔ شهرهای واقعی بدون خطا اجرا میشود", () => {
+ for (const city of cityData) {
+ const province = stateData.find((s) => s.id === city.province_id);
+ const text = buildAboutIntro(city.name, province?.name, city.site_name);
+
+ expect(typeof text).toBe("string");
+ expect(text).not.toContain("undefined");
+ }
+ });
+
+ it("دو شهرِ همvariant با جایگذاری نام تمایز نمیگیرند، با شعار میگیرند", () => {
+ // سنجیده شد: بهبهان و یاسوج روی هر دو صفحه یک variant میگرفتند و تفاوتشان
+ // فقط نام شهر بود — همان چیزی که duplicate را برنمیگرداند.
+ const cityOf = (name) => cityData.find((c) => c.name === name);
+ const behbahan = cityOf("بهبهان");
+ const yasuj = cityOf("یاسوج");
+
+ const strip = (text, city) => text.split(city).join("«شهر»");
+
+ const a = strip(
+ buildAboutIntro(behbahan.name, "خوزستان", behbahan.site_name, behbahan.slogan),
+ behbahan.name
+ );
+ const b = strip(
+ buildAboutIntro(yasuj.name, "کهگیلویه و بویراحمد", yasuj.site_name, yasuj.slogan),
+ yasuj.name
+ );
+
+ expect(a).not.toBe(b);
+ });
+
+ it("هیچ دو شهری روی هیچکدام از دو صفحه متن یکسان نمیگیرند", () => {
+ for (const build of [buildAboutIntro, buildContactIntro]) {
+ const texts = cityData.map((city) => {
+ const province = stateData.find((s) => s.id === city.province_id);
+ return build(city.name, province?.name, city.site_name, city.slogan);
+ });
+
+ expect(new Set(texts).size).toBe(cityData.length);
+ }
+ });
+
+ it("شهر بدون شعار، جملهٔ ناقص یا undefined نمیگیرد", () => {
+ const text = buildAboutIntro("شهر تازه", "استان", "برند", undefined);
+
+ expect(text).not.toContain("undefined");
+ expect(text).not.toContain("شعار ما همین است: .");
+ });
+});
+
+describe("canonical صفحات استاتیک", () => {
+ it("درباره ما و تماس با ما دیگر به دامنهٔ اصلی نمیروند", () => {
+ // این همان چیزی بود که Search Console «Alternate page with proper canonical»
+ // گزارش میکرد؛ صفحه روی هیچ دامنهٔ شهری ایندکس نمیشد.
+ for (const path of ["/about-us", "/contact-us"]) {
+ expect(buildCanonicalUrl("behbahan-nobat.ir", path)).toBe(
+ `https://behbahan-nobat.ir${path}`
+ );
+ expect(buildMainCanonicalUrl(path)).not.toBe(
+ buildCanonicalUrl("behbahan-nobat.ir", path)
+ );
+ }
+ });
+});