# هفت لندینگ‌پیج سئویی با تم صفحهٔ اصلی

## زمینه

سایت عمومی کلینیک‌پرو الان فقط یک صفحه دارد: `/` که با
`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` | **جدید** — مودال ثبت‌نام، مشترک بین خانه و لندینگ‌ها |

## وضعیت فعلی

کنترلر فعلی:

```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` از این تغییر متأثر نیست؛ این صفحات روی دامنهٔ خود کلینیک‌پرو
  هستند.
