feat: add landing page templates and registration modal
- 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.
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
# هفت لندینگپیج سئویی با تم صفحهٔ اصلی
|
||||
|
||||
## زمینه
|
||||
|
||||
سایت عمومی کلینیکپرو الان فقط یک صفحه دارد: `/` که با
|
||||
`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` از این تغییر متأثر نیست؛ این صفحات روی دامنهٔ خود کلینیکپرو
|
||||
هستند.
|
||||
Reference in New Issue
Block a user