feat: Enhance metadata generation for About Us and Contact Us pages, implement legacy slug resolution for blog posts, and add city-specific intros

This commit is contained in:
hamed
2026-08-09 07:14:15 +03:30
parent 9cfbd2e002
commit 002f388ac8
12 changed files with 334 additions and 28 deletions
+12 -3
View File
@@ -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,
+28 -3
View File
@@ -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);
+25
View File
@@ -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 (
+8 -1
View File
@@ -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 (
<div
@@ -15,6 +21,7 @@ async function AboutUsPage() {
<div
className=" text-[#525252] text-[16px] font-normal mt-[32px] leading-[28px] md:leading-[32px] lg:leading-[40px] px-[16px] sm:px-[52px] md:px-[15%] text-justify md:text-center flex flex-col gap-[16px] "
>
{cityIntro && <p>{cityIntro}</p>}
<p>
{siteName} یک سامانهی آنلاین نوبتدهی پزشکی است که با هدف سادهتر کردن
دسترسی مردم به خدمات سلامت طراحی شده است. ما تلاش میکنیم تجربهی رزرو
+6 -1
View File
@@ -1,6 +1,6 @@
import Links from "./Links";
function Call({ matchedCity, isDefault }) {
function Call({ matchedCity, isDefault, cityIntro }) {
return (
<div
className={`lg:min-w-[calc(50%_+_70px)] bg-banner-about !bg-center !bg-no-repeat !bg-cover h-fit pt-[16px] sm:pt-[29px] md:pt-[42px] lg:pt-[55px] bg-contact rounded-[8px] pb-[30px] sm:pb-[42px] md:pb-[55px] overflow-hidden lg:pb-[171px] px-[16px] sm:px-[27px] md:px-[39px] lg:px-[48px] ${isDefault ? "lg:pl-[18%]" : "w-full"}`}
@@ -9,6 +9,11 @@ function Call({ matchedCity, isDefault }) {
<h2 className="text-[#FAFAFA] leading-[26px] text-[14px] md:text-[16px] font-normal mt-6">
{matchedCity?.footer_description || "برای ارتباط با ما از راه های زیر استفاده کنید"}
</h2>
{cityIntro && (
<p className="text-[#E5E5F5] leading-[26px] text-[13px] md:text-[15px] font-normal mt-4">
{cityIntro}
</p>
)}
<Links matchedCity={matchedCity} />
</div>
);
+13 -2
View File
@@ -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 (
<div className="w-full pt-[24px] sm:pt-[56px] md:pt-[88px] lg:pt-[120px] mt-[56px] padding-responsive flex flex-col lg:flex-row items-center justify-between h-fit">
<Call matchedCity={matchedCity} isDefault={isRoot} />
<Call matchedCity={matchedCity} isDefault={isRoot} cityIntro={cityIntro} />
{isRoot && <Connect />}
</div>
);
+6
View File
@@ -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"
}
+5 -10
View File
@@ -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);
}
/**
+34
View File
@@ -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);
});
});
+9 -8
View File
@@ -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 نسازند.
+70
View File
@@ -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)
);
}
+118
View File
@@ -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)
);
}
});
});