- 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.
326 lines
16 KiB
Markdown
326 lines
16 KiB
Markdown
# هفت لندینگپیج سئویی با تم صفحهٔ اصلی
|
||
|
||
## زمینه
|
||
|
||
سایت عمومی کلینیکپرو الان فقط یک صفحه دارد: `/` که با
|
||
`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` از این تغییر متأثر نیست؛ این صفحات روی دامنهٔ خود کلینیکپرو
|
||
هستند.
|