- Implemented header and hero section in _header.html.twig and _hero_art.html.twig. - Created a pre-registration modal in _reg_modal.html.twig with form fields and validation. - Added page scripts for dynamic behavior and interaction in _page_scripts.html.twig. - Developed landing page structure in landing.html.twig, integrating header, footer, and modal. - Introduced tests for landing page rendering and registry validation in LandingPageTest.php and LandingRegistryTest.php.
16 KiB
هفت لندینگپیج سئویی با تم صفحهٔ اصلی
زمینه
سایت عمومی کلینیکپرو الان فقط یک صفحه دارد: / که با
src/Shared/Controller/HomeController.php رندر میشود و کل محتوایش در یک فایل
۹۹۳ خطی templates/public/home.html.twig هاردکد است — شامل <head> کامل، متای سئو،
JSON-LD، هدر، شش بخش محتوا، مودال ثبتنام و فوتر.
sitemap.xml هم فقط همان یک URL را دارد
(src/Shared/Controller/SeoController.php).
برای هفت کلیدواژهٔ تجاری، هفت صفحهٔ فرود جدا لازم است. هرکدام باید عنوان، توضیحات،
canonical، h1 و متن مخصوص خودش را داشته باشد، ولی ظاهرش دقیقاً همان تم صفحهٔ اصلی
باشد.
مشکل / هدف
هفت لندینگ با این کلیدواژهها:
| کلیدواژه | آدرس |
|---|---|
| نرمافزار مدیریت کلینیک زیبایی | /نرم-افزار-مدیریت-کلینیک-زیبایی |
| نرمافزار مدیریت کلینیک دندانپزشکی | /نرم-افزار-مدیریت-کلینیک-دندانپزشکی |
| نرمافزار مدیریت کلینیک فیزیوتراپی | /نرم-افزار-مدیریت-کلینیک-فیزیوتراپی |
| سیستمهای جامع درمانگاهی | /سیستم-جامع-درمانگاهی |
| نرمافزار مجانی مدیریت کلینیک | /نرم-افزار-رایگان-مدیریت-کلینیک |
| نرمافزار مدیریت مطب | /نرم-افزار-مدیریت-مطب |
| CRM کلینیکها | /crm-کلینیک |
تصمیم معماری (تأییدشده): یک قالب مشترک + رجیستری PHP. کپیکردن
home.html.twig هفت بار یعنی هر تغییر تم باید هشت بار تکرار شود و بعد از دو ماه هشت
نسخهٔ واگرا داریم. متن هر صفحه داده است، نه کد.
معیار پذیرش
- ✅ موفق:
GET /نرم-افزار-مدیریت-کلینیک-زیبایی→ ۲۰۰ با<title>و<meta name="description">و<link rel="canonical">و<h1>مخصوص همان صفحه؛ ظاهرش همان تم صفحهٔ اصلی است (همان CSS، همان هدر و فوتر)؛ مودال «ثبت نام» باز میشود و فرمش به/api/v1/pre-registrationارسال میکند. - ❌ خطا:
GET /یک-اسلاگ-ناموجود→ ۴۰۴ استاندارد Symfony، نه صفحهٔ خالی و نه ۲۰۰ با محتوای پیشفرض. - ⚠️ مرزی:
GET /sitemap.xmlهر هشت URL را دارد (خانه + هفت لندینگ) و XML معتبر است؛ هیچ دو صفحهایtitleیاh1یاcanonicalیکسان ندارند؛ روی دامنهٔ.ddev.siteمقدارrobots.txtهمچنانDisallow: /میماند.
فایلهای مرتبط
| فایل | نقش |
|---|---|
src/Shared/Controller/HomeController.php |
کنترلر فعلی صفحهٔ اصلی |
src/Shared/Controller/SeoController.php |
robots.txt و sitemap.xml — باید لندینگها را بشناسد |
templates/public/home.html.twig |
تم مرجع؛ هدر، فوتر، مودال و بخشها از اینجا میآیند |
assets/home/styles.css |
تمام کلاسهای تم (hero, fcols, band, devices, specs, btn-*, wrap) |
webpack.config.js |
entry بهنام home که لندینگها هم از آن استفاده میکنند |
src/Shared/Landing/ |
جدید — رجیستری و مدل لندینگ |
templates/public/landing.html.twig |
جدید — قالب مشترک |
templates/public/_reg_modal.html.twig |
جدید — مودال ثبتنام، مشترک بین خانه و لندینگها |
وضعیت فعلی
کنترلر فعلی:
class HomeController extends AbstractController
{
public function __construct(private readonly AltchaService $altcha) {}
#[Route('/', name: 'home', methods: ['GET'])]
public function index(): Response
{
return $this->render('public/home.html.twig', [
'altcha_enabled' => $this->altcha->enabled(),
]);
}
}
sitemap.xml فقط یک URL دارد:
#[Route('/sitemap.xml', name: 'seo_sitemap', methods: ['GET'])]
public function sitemap(Request $request): Response
{
$base = $request->getSchemeAndHttpHost();
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
. '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n"
. ' <url>' . "\n"
. " <loc>{$base}/</loc>\n"
. ' <changefreq>weekly</changefreq>' . "\n"
. ' <priority>1.0</priority>' . "\n"
. ' </url>' . "\n"
. '</urlset>' . "\n";
اسکلت صفحهٔ اصلی — خطوطی که مرزهای قابل استخراجاند:
66: {{ encore_entry_link_tags('home') }}
74: <header class="site-header" id="header"> … تا 99
101: <main> … تا 744
600: <script> ← منطق مودال ثبتنام (submitReg)
747: <footer class="site-footer" id="contact"> … تا 822
991: {{ encore_entry_script_tags('home') }}
مودال ثبتنام به altcha_enabled وابسته است و به این اندپوینت میفرستد:
fetch('/api/v1/pre-registration', {
وظایف
۱. مدل و رجیستری لندینگ
src/Shared/Landing/LandingPage.php — یک value object فقطخواندنی:
final readonly class LandingPage
{
/**
* @param list<array{title: string, body: string}> $features
* @param list<array{q: string, a: string}> $faq
* @param list<string> $related اسلاگ لندینگهای مرتبط
*/
public function __construct(
public string $slug,
public string $metaTitle,
public string $metaDescription,
public string $keywords,
public string $h1,
public string $lead,
public array $features,
public array $faq,
public array $related,
) {}
}
src/Shared/Landing/LandingRegistry.php — تنها منبع تعریف هفت صفحه:
final class LandingRegistry
{
/** @return array<string, LandingPage> کلید = اسلاگ */
public function all(): array { … }
public function find(string $slug): ?LandingPage { … }
}
چرا رجیستری و نه دیتابیس: متن این صفحات محتوای بازاریابی است و با کد دیپلوی میشود؛ جدول و CRUD برای هفت رکوردِ کمتغییر، پیچیدگی بیمصرف است (guidelines §۵). اگر بعداً لازم شد از پنل ویرایش شود، همین interface جای تعویض دارد.
نحوه تست: یک تست PHPUnit در tests/Shared/LandingRegistryTest.php:
هر هفت اسلاگ وجود دارند؛ هیچ metaTitle یا h1 تکراری نیست؛ طول
metaDescription بین ۱۲۰ و ۱۶۰ نویسه است؛ هر related به اسلاگی اشاره میکند که
واقعاً در رجیستری هست.
۲. استخراج هدر، فوتر و مودال از صفحهٔ اصلی
سه partial بساز و home.html.twig را طوری تغییر بده که همانها را include کند —
یعنی خروجی رندرشدهٔ / هیچ تغییری نکند:
templates/public/_header.html.twig(خطوط ۷۴ تا ۹۹)templates/public/_footer.html.twig(خطوط ۷۴۷ تا ۸۲۲)templates/public/_reg_modal.html.twig(مودال + اسکریپتsubmitReg)
نحوه تست: خروجی / را قبل و بعد ذخیره کن و diff بگیر؛ باید یکسان باشد:
ddev exec curl -s http://localhost/ > /tmp/home-before.html
# … بعد از تغییر
ddev exec curl -s http://localhost/ > /tmp/home-after.html
diff /tmp/home-before.html /tmp/home-after.html # باید خالی باشد
۳. قالب مشترک لندینگ
templates/public/landing.html.twig — همان اسکلت home.html.twig ولی دادهمحور:
<head>باpage.metaTitle،page.metaDescription،page.keywordsو<link rel="canonical" href="{{ base }}/{{ page.slug }}">{{ encore_entry_link_tags('home') }}و{{ encore_entry_script_tags('home') }}— همان entry، پس تم دقیقاً یکی است و CSS دومی ساخته نمیشود- هدر و فوتر و مودال با
includeاز وظیفهٔ ۲ - بخش hero با
page.h1وpage.leadو همان کلاسهایhero,hero-grid,hero-copy,btn btn-coral,btn btn-blue - بخش امکانات با کلاسهای
fcols,fcols-grid,fcolرویpage.features - بخش پرسشهای متداول روی
page.faq - بخش «راهنماهای مرتبط» با لینک داخلی به
page.related— لینک داخلی بین لندینگها برای سئو لازم است و صفحات را یتیم نمیگذارد
قید مهم: هیچ کلاس CSS جدیدی تعریف نکن. اگر بخشی از تم لازم است که کلاسش وجود
ندارد، از همان بخشهای موجود (band, specs, devices) استفاده کن. تم باید یکی
بماند، نه شبیه.
نحوه تست: بعد از وظیفهٔ ۴، هر هفت آدرس را باز کن و با اسکرینشات با / مقایسه
کن.
۳.۱ JSON-LD هر صفحه
در قالب، سه schema بگذار:
SoftwareApplicationباapplicationCategory: "BusinessApplication"وoffers— برای صفحهٔ رایگانprice: "0"FAQPageازpage.faq— این برای کلیدواژههای تجاری در نتایج گوگل شانس rich result داردBreadcrumbListبا دو سطح: خانه ← همین صفحه
نحوه تست: خروجی هر صفحه را در
validator.schema.org بگذار، یا حداقل با
python3 -c "import json,sys; json.load(sys.stdin)" روی محتوای هر بلاک
application/ld+json صحت JSON را بسنج — Twig با نقلقول فارسی راحت JSON را خراب
میکند.
۴. کنترلر لندینگ
src/Shared/Controller/LandingController.php:
final class LandingController extends AbstractController
{
public function __construct(
private readonly LandingRegistry $registry,
private readonly AltchaService $altcha,
) {}
#[Route('/{slug}', name: 'landing_show', methods: ['GET'], priority: -10)]
public function show(string $slug): Response
{
$page = $this->registry->find($slug);
if ($page === null) {
throw $this->createNotFoundException();
}
return $this->render('public/landing.html.twig', [
'page' => $page,
'altcha_enabled' => $this->altcha->enabled(),
]);
}
}
چرا priority: -10: روت /{slug} هر مسیر تکبخشی را میگیرد. بدون اولویت
منفی، ممکن است جلوی /admin، /robots.txt یا مسیرهای دیگر را بگیرد. با اولویت
منفی، آخرین گزینه است.
نحوه تست:
ddev exec php bin/console debug:router | grep landing
for s in نرم-افزار-مدیریت-کلینیک-زیبایی crm-کلینیک; do
curl -sk -o /dev/null -w "%{http_code} $s\n" "https://clinic-pro.ddev.site/$s"
done
curl -sk -o /dev/null -w "%{http_code} اسلاگ-ناموجود\n" https://clinic-pro.ddev.site/اسلاگ-ناموجود # باید 404
curl -sk -o /dev/null -w "%{http_code} /admin\n" https://clinic-pro.ddev.site/admin # باید 200
curl -sk -o /dev/null -w "%{http_code} /robots.txt\n" https://clinic-pro.ddev.site/robots.txt # باید 200
سه خط آخر مهمتریناند: اگر روت لندینگ حریص باشد، پنل ادمین را میخورد.
۵. sitemap و لینک داخلی
SeoController::sitemap() را طوری تغییر بده که LandingRegistry را تزریق بگیرد و
هر هفت لندینگ را با priority: 0.8 اضافه کند. اسلاگ فارسی باید در XML
percent-encode شود، وگرنه XML نامعتبر است:
$loc = $base . '/' . rawurlencode($page->slug);
در فوتر (_footer.html.twig) یک ستون «راهکارها» با لینک به هر هفت صفحه اضافه کن.
بدون این، لندینگها فقط از sitemap دیده میشوند و از داخل سایت لینک نمیگیرند.
نحوه تست:
curl -sk https://clinic-pro.ddev.site/sitemap.xml | python3 -c "
import sys, xml.etree.ElementTree as ET
root = ET.fromstring(sys.stdin.read())
ns = {'s': 'http://www.sitemaps.org/schemas/sitemap/0.9'}
locs = [u.find('s:loc', ns).text for u in root.findall('s:url', ns)]
print(len(locs), 'url'); [print(' ', l) for l in locs]
assert len(locs) == 8, 'باید هشت آدرس باشد'
"
۶. تست خودکار محتوای صفحات
tests/Shared/LandingPageTest.php با ApiTestCase یا WebTestCase:
- هر هفت اسلاگ → ۲۰۰
titleوh1وcanonicalهر صفحه یکتا و متعلق به خودش است- اسلاگ ناموجود → ۴۰۴
/هنوز ۲۰۰ میدهد وh1قدیمیاش را داردsitemap.xmlهشت<loc>دارد
نحوه تست: ddev exec php bin/phpunit tests/Shared/LandingPageTest.php
نکات مهم
- متن هر صفحه باید واقعاً متفاوت باشد. هفت صفحه با یک متن و فقط عوضشدن کلمهٔ «زیبایی/دندانپزشکی/فیزیوتراپی» از نگاه گوگل محتوای تکراری است و هر هفتتا را پایین میکشد. برای هر صنف، دردِ خودش را بنویس: کلینیک زیبایی → دورههای چندجلسهای و مصرف کالا؛ دندانپزشکی → پروندهٔ دندان و بیمهٔ تکمیلی؛ فیزیوتراپی → جلسات درمانی و نوبت تکراری؛ درمانگاه → چند پزشک و چند بخش و منابع مشترک؛ مطب → سادگی و یکنفره بودن؛ CRM → پیگیری بیمار و یادآوری و بازگشت مراجع.
- صفحهٔ «رایگان» باید صادق باشد. پلن
freeواقعاً در محصول هست (SubscriptionPlanبا نامfree)، پس ادعای رایگان درست است — ولی در همان صفحه صریح بنویس چه چیزی در پلن رایگان هست و چه چیزی نیست. وعدهٔ نادرست، هم نرخ تبدیل را خراب میکند هم اعتماد را. - تم را کپی نکن، مشترک کن. اگر بعد از این کار، تغییر رنگ دکمه در صفحهٔ اصلی بهطور خودکار در هر هفت لندینگ دیده نشد، یعنی وظیفهٔ ۲ و ۳ درست انجام نشدهاند.
- الگو: Registry + Value Object. دلیل انتخاب: هفت نمونهٔ همشکل که فقط دادهشان فرق دارد. این «abstraction برای آینده» نیست؛ همین حالا هفت مصرفکننده دارد.
- روت
/{slug}با اسلاگ فارسی کار میکند ولی در لاگ و ابزارها percent-encoded دیده میشود. این عادی است و نباید «درست» شود. - این تغییر backend عمومی است و API ندارد، پس
docs/api/دست نمیخورد. در عوض یک یادداشت کوتاه درREADME.MDیاdocs/بنویس که لندینگها کجا تعریف میشوند — وگرنه نفر بعدی دنبال فایل Twig هر صفحه میگردد. nobat724_frontاز این تغییر متأثر نیست؛ این صفحات روی دامنهٔ خود کلینیکپرو هستند.