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) + ); + } + }); +});