fix(seo): shorten the footer and center the related-solutions cards

Every landing page was listed in the footer, so adding keyword clusters made
that one column taller than the rest of the footer put together. It now lists
six — the head terms and the two biggest specialties — and the rest stay
reachable through the "other solutions" block each page already renders.

That trade only holds if nothing falls out of the link graph, so the old test
asserting "the footer links to every page" is replaced by one that walks the
related-page graph from the footer links and fails when any page is not
reachable. Physiotherapy was the one page that would have been orphaned; the
polyclinic page now links to it.

The related-solutions heading said "for your trade", which reads oddly for the
clinics it is addressing, and its cards were left-aligned in a centered
section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-18 18:26:59 +03:30
co-authored by Claude Opus 5
parent 8e4bb3ca7b
commit 40b1da64be
5 changed files with 63 additions and 11 deletions
+22 -1
View File
@@ -29,12 +29,33 @@ final class LandingRegistry
/** @var array<string, LandingPage>|null ساخت تنبل؛ هر درخواست حداکثر یک بار می‌سازدش. */
private ?array $pages = null;
/**
* صفحه‌هایی که در فوتر می‌آیند.
*
* فوتر فهرست کامل نیست: با هر خوشهٔ کلیدواژهٔ تازه، ستونِ «راهکارها» بلندتر از
* بقیهٔ فوتر می‌شد و صفحه را زشت می‌کرد. بقیهٔ صفحه‌ها از بخش «راهکارهای دیگر»
* همین صفحه‌ها و از sitemap در دسترس‌اند — {@see LandingPageTest} تضمین می‌کند
* هیچ‌کدام از این فهرست جا نمانند.
*
* @var list<string>
*/
private const FEATURED = [self::CLINIC, self::OFFICE, self::BOOKING, self::DENTAL, self::BEAUTY, self::FREE];
/** @return array<string, LandingPage> کلید = اسلاگ */
public function all(): array
{
return $this->pages ??= $this->build();
}
/** @return list<LandingPage> صفحه‌های فوتر، به همان ترتیبِ تعریف‌شده */
public function featured(): array
{
return array_values(array_filter(array_map(
fn (string $slug): ?LandingPage => $this->find($slug),
self::FEATURED,
)));
}
public function find(string $slug): ?LandingPage
{
return $this->all()[$slug] ?? null;
@@ -134,7 +155,7 @@ final class LandingRegistry
['q' => 'تداخل اتاق و دستگاه چطور کنترل می‌شود؟', 'a' => 'با تعریف منبع. نوبت علاوه بر وقت پزشک، وقت منبع را هم اشغال می‌کند.'],
['q' => 'گزارش مالی تفکیک‌شده دارد؟', 'a' => 'بله، فهرست پرداخت‌ها با وضعیت تسویه و خلاصهٔ مجموع، پرداخت‌شده و تسویه‌نشده.'],
],
related: [self::CLINIC, self::ACCOUNTING, self::DENTAL],
related: [self::CLINIC, self::ACCOUNTING, self::PHYSIOTHERAPY],
),
new LandingPage(
slug: self::FREE,
+1 -1
View File
@@ -36,7 +36,7 @@
<div class="footer-col">
<h4>راهکارها</h4>
<div class="foot-links">
{% for landing in landing_pages.all() %}
{% for landing in landing_pages.featured() %}
<a href="/{{ landing.slug }}">{{ landing.h1 }}</a>
{% endfor %}
</div>
+2 -2
View File
@@ -130,12 +130,12 @@
<div class="wrap">
<div class="specs-head reveal">
<span class="eyebrow">راهکارهای دیگر</span>
<h2>کلینیک پرو برای صنف شما</h2>
<h2>کلینیک پرو برای کلینیک شما</h2>
<p>هر مجموعه نیاز خودش را دارد؛ نسخهٔ متناسب با کار خود را ببینید.</p>
</div>
<div class="specs-grid">
{% for item in related %}
<a class="spec-card reveal" href="/{{ item.slug }}" style="text-decoration:none;color:inherit;display:block">
<a class="spec-card reveal" href="/{{ item.slug }}" style="text-decoration:none;color:inherit;display:block;text-align:center">
<h3>{{ item.h1 }}</h3>
<p style="font-size:13.5px;color:var(--muted);line-height:1.9;margin-top:12px">{{ item.metaDescription }}</p>
</a>
+29 -3
View File
@@ -94,17 +94,43 @@ class LandingPageTest extends WebTestCase
}
/** بدون لینک داخلی، صفحات فقط از sitemap دیده می‌شوند. */
public function testFooterLinksToEveryLandingOnEveryPage(): void
public function testFooterLinksToTheFeaturedLandingsOnEveryPage(): void
{
foreach (['/', '/' . LandingRegistry::CRM] as $path) {
$html = $this->html($path);
foreach (array_keys($this->registry->all()) as $slug) {
self::assertStringContainsString('href="/' . $slug . '"', $html, "لینک «{$slug}» در {$path} نیست");
foreach ($this->registry->featured() as $page) {
self::assertStringContainsString('href="/' . $page->slug . '"', $html, "لینک «{$page->slug}» در {$path} نیست");
}
}
}
/**
* فوتر دیگر همهٔ صفحه‌ها را فهرست نمی‌کند، پس باید ثابت بماند که از همان چند
* لینکِ فوتر، با دنبال‌کردن «راهکارهای دیگر»، به هر صفحه می‌رسیم. صفحه‌ای که فقط
* در sitemap باشد عملاً بدون لینک داخلی است و رتبه نمی‌گیرد.
*/
public function testEveryLandingIsReachableFromTheFooterLinks(): void
{
$pages = $this->registry->all();
$queue = array_map(static fn ($page): string => $page->slug, $this->registry->featured());
$reached = array_flip($queue);
while ($queue !== []) {
$slug = array_shift($queue);
foreach ($pages[$slug]->related as $next) {
if (!isset($reached[$next])) {
$reached[$next] = true;
$queue[] = $next;
}
}
}
foreach (array_keys($pages) as $slug) {
self::assertArrayHasKey($slug, $reached, "«{$slug}» از هیچ صفحه‌ای لینک نمی‌گیرد");
}
}
public function testStructuredDataIsValidJson(): void
{
foreach (array_keys($this->registry->all()) as $slug) {
+9 -4
View File
@@ -27,12 +27,17 @@ class LandingRegistryTest extends TestCase
// تعداد صفحه‌ها با هر خوشهٔ کلیدواژهٔ تازه بالا می‌رود، پس عدد ثابت اینجا فقط
// یک تستِ شکننده می‌سازد. آنچه باید ثابت بماند این است که هر ثابتِ کلاس
// واقعاً صفحه‌ای دارد — ثابتی که ساخته نشده باشد، لینک داخلیِ ۴۰۴ می‌سازد.
$constants = (new \ReflectionClass(LandingRegistry::class))->getConstants();
// فقط ثابت‌های عمومی: اسلاگ‌ها همان‌هایند و ثابت‌های داخلی (مثل فهرست فوتر)
// صفحه نیستند.
foreach ((new \ReflectionClass(LandingRegistry::class))->getReflectionConstants() as $constant) {
if (!$constant->isPublic()) {
continue;
}
self::assertSame(count($constants), count($slugs));
foreach ($constants as $name => $slug) {
self::assertContains($slug, $slugs, "ثابت {$name} صفحه‌ای ندارد");
self::assertContains($constant->getValue(), $slugs, "ثابت {$constant->getName()} صفحه‌ای ندارد");
}
self::assertCount(count($slugs), array_unique($slugs));
}
public function testUnknownSlugReturnsNull(): void