# هفت لندینگ‌پیج سئویی با تم صفحهٔ اصلی ## زمینه سایت عمومی کلینیک‌پرو الان فقط یک صفحه دارد: `/` که با `src/Shared/Controller/HomeController.php` رندر می‌شود و کل محتوایش در یک فایل ۹۹۳ خطی `templates/public/home.html.twig` هاردکد است — شامل `` کامل، متای سئو، JSON-LD، هدر، شش بخش محتوا، مودال ثبت‌نام و فوتر. `sitemap.xml` هم فقط همان یک URL را دارد (`src/Shared/Controller/SeoController.php`). برای هفت کلیدواژهٔ تجاری، هفت صفحهٔ فرود جدا لازم است. هرکدام باید عنوان، توضیحات، canonical، `h1` و متن مخصوص خودش را داشته باشد، ولی ظاهرش دقیقاً همان تم صفحهٔ اصلی باشد. ## مشکل / هدف هفت لندینگ با این کلیدواژه‌ها: | کلیدواژه | آدرس | |---|---| | نرم‌افزار مدیریت کلینیک زیبایی | `/نرم-افزار-مدیریت-کلینیک-زیبایی` | | نرم‌افزار مدیریت کلینیک دندان‌پزشکی | `/نرم-افزار-مدیریت-کلینیک-دندانپزشکی` | | نرم‌افزار مدیریت کلینیک فیزیوتراپی | `/نرم-افزار-مدیریت-کلینیک-فیزیوتراپی` | | سیستم‌های جامع درمانگاهی | `/سیستم-جامع-درمانگاهی` | | نرم‌افزار مجانی مدیریت کلینیک | `/نرم-افزار-رایگان-مدیریت-کلینیک` | | نرم‌افزار مدیریت مطب | `/نرم-افزار-مدیریت-مطب` | | CRM کلینیک‌ها | `/crm-کلینیک` | **تصمیم معماری (تأییدشده):** یک قالب مشترک + رجیستری PHP. کپی‌کردن `home.html.twig` هفت بار یعنی هر تغییر تم باید هشت بار تکرار شود و بعد از دو ماه هشت نسخهٔ واگرا داریم. متن هر صفحه داده است، نه کد. ## معیار پذیرش - ✅ موفق: `GET /نرم-افزار-مدیریت-کلینیک-زیبایی` → ۲۰۰ با `` و `<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` | **جدید** — مودال ثبت‌نام، مشترک بین خانه و لندینگ‌ها | ## وضعیت فعلی کنترلر فعلی: ```php 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 دارد: ```php #[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` وابسته است و به این اندپوینت می‌فرستد: ```js fetch('/api/v1/pre-registration', { ``` ## وظایف ### ۱. مدل و رجیستری لندینگ `src/Shared/Landing/LandingPage.php` — یک value object فقط‌خواندنی: ```php 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` — تنها منبع تعریف هفت صفحه: ```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 بگیر؛ باید یکسان باشد: ```bash 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](https://validator.schema.org) بگذار، یا حداقل با `python3 -c "import json,sys; json.load(sys.stdin)"` روی محتوای هر بلاک `application/ld+json` صحت JSON را بسنج — Twig با نقل‌قول فارسی راحت JSON را خراب می‌کند. ### ۴. کنترلر لندینگ `src/Shared/Controller/LandingController.php`: ```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` یا مسیرهای دیگر را بگیرد. با اولویت منفی، آخرین گزینه است. **نحوه تست:** ```bash 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 نامعتبر است: ```php $loc = $base . '/' . rawurlencode($page->slug); ``` در فوتر (`_footer.html.twig`) یک ستون «راهکارها» با لینک به هر هفت صفحه اضافه کن. بدون این، لندینگ‌ها فقط از sitemap دیده می‌شوند و از داخل سایت لینک نمی‌گیرند. **نحوه تست:** ```bash 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` از این تغییر متأثر نیست؛ این صفحات روی دامنهٔ خود کلینیک‌پرو هستند.