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"; import { getStateInfo } from "@/lib/getStateInfo";
export async function generateMetadata() { export async function generateMetadata() {
const { matchedCity } = await getStateInfo(); const { matchedCity, matchedState, isRoot } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724"; const siteName = matchedCity?.site_name || "نوبت 724";
const title = `درباره ما | ${siteName}`; const cityName = isRoot ? null : matchedCity?.name;
const description = `آشنایی با ${siteName}، سیستم آنلاین نوبت‌دهی پزشکی. هدف ما ارائه خدمات سریع و کارآمد رزرو نوبت پزشکی برای همه مردم است.`; 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"; const image = "/assets/images/og-image.png";
return { return {
title, title,
+28 -3
View File
@@ -1,7 +1,7 @@
import { cache } from "react"; import { cache } from "react";
import BlogPage from "@/components/blog"; import BlogPage from "@/components/blog";
import Layout from "@/components/layout/StLayout"; import Layout from "@/components/layout/StLayout";
import { notFound } from "next/navigation"; import { notFound, permanentRedirect } from "next/navigation";
import { fetchReq } from "@/lib/req"; import { fetchReq } from "@/lib/req";
import { getStateInfo } from "@/lib/getStateInfo"; import { getStateInfo } from "@/lib/getStateInfo";
import { getBlogCanonicalOrigin } from "@/lib/getCanonicalUrl"; import { getBlogCanonicalOrigin } from "@/lib/getCanonicalUrl";
@@ -13,6 +13,7 @@ import {
import { safeJsonLd } from "@/lib/sanitize"; import { safeJsonLd } from "@/lib/sanitize";
import { decodeRouteParam } from "@/lib/routeParams"; import { decodeRouteParam } from "@/lib/routeParams";
import { normalizeBlog, blogCover } from "@/helper"; import { normalizeBlog, blogCover } from "@/helper";
import legacySlugs from "@/data/blogLegacySlugs.json";
const API_URL = process.env.NEXT_PUBLIC_API_URL; const API_URL = process.env.NEXT_PUBLIC_API_URL;
@@ -39,13 +40,31 @@ async function getScopedBlog(slug) {
return getBlog(slug, domainScopeCityId({ matchedCity, isRoot })); 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 }) { export async function generateMetadata({ params }) {
const { slug: rawSlug } = await params; const { slug: rawSlug } = await params;
const slug = decodeRouteParam(rawSlug); const slug = decodeRouteParam(rawSlug);
const { matchedCity } = await getStateInfo(); const { matchedCity } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724"; const siteName = matchedCity?.site_name || "نوبت 724";
const blog = await getScopedBlog(slug); const blog = await resolveScopedBlog(slug);
if (!blog) notFound(); if (!blog) notFound();
try { try {
@@ -123,9 +142,15 @@ async function Blog({ params }) {
const slug = decodeRouteParam(rawSlug); const slug = decodeRouteParam(rawSlug);
const slugPath = encodeURIComponent(slug); const slugPath = encodeURIComponent(slug);
const blog = normalizeBlog(await getScopedBlog(slug)); const blog = normalizeBlog(await resolveScopedBlog(slug));
if (!blog) notFound(); if (!blog) notFound();
// اسلاگ کهنه (عنوان عوض شده یا نسل لاتین) به آدرس قطعی منتقل می‌شود؛ دو URL برای
// یک محتوا باقی نمی‌ماند و لینک‌های قدیمیِ ایندکس‌شده اعتبارشان را می‌برند.
if (blog.slug && blog.slug !== slug) {
permanentRedirect(`/blog/${encodeURIComponent(blog.slug)}`);
}
const blogCityId = extractEntityCityId(blog); const blogCityId = extractEntityCityId(blog);
const blogCity = findCityById(blogCityId); const blogCity = findCityById(blogCityId);
const origin = getBlogCanonicalOrigin(blogCityId); const origin = getBlogCanonicalOrigin(blogCityId);
+25
View File
@@ -1,5 +1,30 @@
import ContactUsPage from "@/components/contactUs"; import ContactUsPage from "@/components/contactUs";
import Layout from "@/components/layout/StLayout"; 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() { function ContactUs() {
return ( return (
+8 -1
View File
@@ -1,11 +1,17 @@
import { getStateInfo } from "@/lib/getStateInfo"; import { getStateInfo } from "@/lib/getStateInfo";
import { buildAboutIntro } from "@/lib/staticPageIntro";
import Head from "./Head"; import Head from "./Head";
import Services from "./Services"; import Services from "./Services";
async function AboutUsPage() { async function AboutUsPage() {
const { matchedCity } = await getStateInfo(); const { matchedCity, matchedState, isRoot } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت ۷۲۴"; const siteName = matchedCity?.site_name || "نوبت ۷۲۴";
const slogan = matchedCity?.slogan; const slogan = matchedCity?.slogan;
// این صفحه self-canonical است؛ بدون متن شهری، ۳۵ دامنه یک محتوا اعلام می‌کردند.
// دامنهٔ ریشه شهر نیست، پس متن شهری نمی‌گیرد.
const cityIntro = isRoot
? ""
: buildAboutIntro(matchedCity?.name, matchedState?.name, siteName, slogan);
return ( return (
<div <div
@@ -15,6 +21,7 @@ async function AboutUsPage() {
<div <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] " 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> <p>
{siteName} یک سامانهی آنلاین نوبتدهی پزشکی است که با هدف سادهتر کردن {siteName} یک سامانهی آنلاین نوبتدهی پزشکی است که با هدف سادهتر کردن
دسترسی مردم به خدمات سلامت طراحی شده است. ما تلاش میکنیم تجربهی رزرو دسترسی مردم به خدمات سلامت طراحی شده است. ما تلاش میکنیم تجربهی رزرو
+6 -1
View File
@@ -1,6 +1,6 @@
import Links from "./Links"; import Links from "./Links";
function Call({ matchedCity, isDefault }) { function Call({ matchedCity, isDefault, cityIntro }) {
return ( return (
<div <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"}`} 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"> <h2 className="text-[#FAFAFA] leading-[26px] text-[14px] md:text-[16px] font-normal mt-6">
{matchedCity?.footer_description || "برای ارتباط با ما از راه های زیر استفاده کنید"} {matchedCity?.footer_description || "برای ارتباط با ما از راه های زیر استفاده کنید"}
</h2> </h2>
{cityIntro && (
<p className="text-[#E5E5F5] leading-[26px] text-[13px] md:text-[15px] font-normal mt-4">
{cityIntro}
</p>
)}
<Links matchedCity={matchedCity} /> <Links matchedCity={matchedCity} />
</div> </div>
); );
+13 -2
View File
@@ -1,13 +1,24 @@
import { getStateInfo } from "@/lib/getStateInfo"; import { getStateInfo } from "@/lib/getStateInfo";
import { buildContactIntro } from "@/lib/staticPageIntro";
import Call from "./call/Call"; import Call from "./call/Call";
import Connect from "./Connect"; import Connect from "./Connect";
async function ContactUsPage() { 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 ( 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"> <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 />} {isRoot && <Connect />}
</div> </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; return !hasSpecialty && !hasLocation;
} }
/**
* پروفایل بدون مالک (claim نشده). داده‌اش از منبع بیرونی آمده و صاحبش تأییدش نکرده،
* پس محتوای قابل اتکا برای ایندکس نیست. هم پاسخ لیست و هم پاسخ جزئیات `owner_status`
* دارند؛ نبودِ فیلد هم «مالک ندارد» تفسیر می‌شود تا هیچ‌وقت به‌طور خاموش ایندکس نشود.
*/
export function isUnclaimedDoctor(doctor) {
return doctor?.owner_status !== "claimed";
}
/** /**
* منبع واحدِ ایندکس‌پذیری پزشک: هم صفحهٔ پزشک (robots) و هم sitemap از این استفاده * منبع واحدِ ایندکس‌پذیری پزشک: هم صفحهٔ پزشک (robots) و هم sitemap از این استفاده
* می‌کنند تا واگرا نشوند — چیزی که noindex است نباید در sitemap باشد. * می‌کنند تا واگرا نشوند — چیزی که noindex است نباید در sitemap باشد.
*
* سنجه فقط محتواست، نه مالکیت. شرط `owner_status !== "claimed"` برداشته شد چون
* ۸۰ صفحهٔ یاسوج و ۱۲ صفحهٔ بهبهان را از ایندکس بیرون نگه می‌داشت — پروفایل‌هایی که
* نام و تخصص و نشانی داشتند. claim یک وضعیت اداری است، نه سنجهٔ کیفیت محتوا.
*/ */
export function isNoindexDoctor(doctor) { 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 روی همان دامنهٔ شهری (هر شهر محتوای خودش را دارد) // → self-canonical روی همان دامنهٔ شهری (هر شهر محتوای خودش را دارد)
// C1-b موجودیت : /doctor/[uuid]، /clinic/[uuid]، /blog/[slug] // C1-b موجودیت : /doctor/[uuid]، /clinic/[uuid]، /blog/[slug]
// → دامنهٔ شهرِ همان موجودیت (getEntityCanonical) // → دامنهٔ شهرِ همان موجودیت (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 DEFAULT_HOST = "nobat724.com";
const MAIN_ORIGIN = "https://nobat724.com"; const MAIN_ORIGIN = "https://nobat724.com";
// C1-c — صفحات با محتوای یکسان روی همهٔ دامنه‌ها // C1-c — صفحات با محتوای یکسان روی همهٔ دامنه‌ها
const STATIC_SHARED_PAGES = new Set([ const STATIC_SHARED_PAGES = new Set(["/terms", "/privacy"]);
"/about-us",
"/contact-us",
"/terms",
"/privacy",
]);
// تنها queryهایی که صفحه را از نظر محتوایی متمایز می‌کنند و باید در canonical بمانند. // تنها queryهایی که صفحه را از نظر محتوایی متمایز می‌کنند و باید در canonical بمانند.
// بقیه (فیلتر چندگانه، sort، name) حذف می‌شوند تا duplicate نسازند. // بقیه (فیلتر چندگانه، 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)
);
}
});
});