diff --git a/.claude/prompt/seo-landing-pages.md b/.claude/prompt/seo-landing-pages.md new file mode 100644 index 00000000..ecff266f --- /dev/null +++ b/.claude/prompt/seo-landing-pages.md @@ -0,0 +1,325 @@ +# هفت لندینگ‌پیج سئویی با تم صفحهٔ اصلی + +## زمینه + +سایت عمومی کلینیک‌پرو الان فقط یک صفحه دارد: `/` که با +`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` از این تغییر متأثر نیست؛ این صفحات روی دامنهٔ خود کلینیک‌پرو + هستند. diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 3f795d92..9c462312 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,5 +1,9 @@ twig: file_name_pattern: '*.twig' + globals: + # فوتر مشترک است و در هر دو صفحهٔ اصلی و لندینگ رندر می‌شود؛ global بودن + # رجیستری از پاس‌دادن دستی همان متغیر در هر کنترلر جلوگیری می‌کند. + landing_pages: '@App\Shared\Landing\LandingRegistry' when@test: twig: diff --git a/docs/landing-pages.md b/docs/landing-pages.md new file mode 100644 index 00000000..1ece1958 --- /dev/null +++ b/docs/landing-pages.md @@ -0,0 +1,69 @@ +# صفحات فرود سئویی + +سایت عمومی علاوه بر `/` هفت صفحهٔ فرود دارد، هرکدام روی یک کلیدواژهٔ تجاری. آدرس هر +صفحه فارسی و تخت است تا کلیدواژه در خودِ URL بنشیند. + +| کلیدواژه | آدرس | +|---|---| +| نرم‌افزار مدیریت کلینیک زیبایی | `/نرم-افزار-مدیریت-کلینیک-زیبایی` | +| نرم‌افزار مدیریت کلینیک دندان‌پزشکی | `/نرم-افزار-مدیریت-کلینیک-دندانپزشکی` | +| نرم‌افزار مدیریت کلینیک فیزیوتراپی | `/نرم-افزار-مدیریت-کلینیک-فیزیوتراپی` | +| سیستم جامع درمانگاهی | `/سیستم-جامع-درمانگاهی` | +| نرم‌افزار رایگان مدیریت کلینیک | `/نرم-افزار-رایگان-مدیریت-کلینیک` | +| نرم‌افزار مدیریت مطب | `/نرم-افزار-مدیریت-مطب` | +| CRM کلینیک‌ها | `/crm-کلینیک` | + +## متن هر صفحه کجاست + +در `src/Shared/Landing/LandingRegistry.php` — نه در فایل Twig. هر صفحه یک +`LandingPage` است با عنوان، توضیحات متا، کلیدواژه‌ها، `h1`، لید، فهرست امکانات، +پرسش‌های متداول و اسلاگ صفحات مرتبط. + +برای ویرایش متن، فقط همان کلاس را عوض کن. برای افزودن صفحهٔ جدید، یک `LandingPage` +تازه به `build()` اضافه کن؛ روت، sitemap و لینک فوتر خودشان به‌روز می‌شوند. + +## چرا رجیستری، نه هفت فایل Twig + +قالب هر هفت صفحه یکی است و فقط متنشان فرق دارد. هفت فایل یعنی هر تغییر تم هشت بار +تکرار شود و بعد از مدتی هشت نسخهٔ واگرا داشته باشیم. + +## تم مشترک + +`templates/public/landing.html.twig` همان `encore_entry` صفحهٔ اصلی (`home`) را +بارگذاری می‌کند و این partialها را با آن به اشتراک می‌گذارد: + +``` +templates/public/_header.html.twig +templates/public/_hero_art.html.twig +templates/public/_reg_modal.html.twig +templates/public/_footer.html.twig +templates/public/_page_scripts.html.twig +``` + +یعنی تغییر رنگ دکمه یا متن هدر در صفحهٔ اصلی، خودکار در هر هفت صفحه دیده می‌شود. +`LandingPageTest::testLandingUsesTheHomepageTheme` همین را تضمین می‌کند. + +## روت + +`LandingController` روی `/{slug}` با `priority: -10` می‌نشیند. اولویت منفی لازم است: +این الگو هر مسیر تک‌بخشی را می‌گیرد و بدون آن جلوی `/health`، `/sitemap.xml` و هر +روت ریشه‌ای بعدی را می‌بندد. `LandingPageTest::testExistingRoutesAreNotShadowed` +مراقب همین است. + +## سئو + +هر صفحه `title`، `description`، `keywords` و `canonical` مخصوص خودش دارد، و سه +schema: `SoftwareApplication`، `BreadcrumbList` و `FAQPage`. + +JSON-LD با `json_encode` در Twig ساخته می‌شود نه رشتهٔ دستی — متن فارسی نقل‌قول دارد و +JSONِ دست‌ساز خیلی راحت نامعتبر می‌شود. + +`sitemap.xml` خودش هر هفت صفحه را اضافه می‌کند و اسلاگ فارسی را percent-encode +می‌کند. فوتر مشترک هم به همهٔ آن‌ها لینک می‌دهد تا صفحات یتیم نمانند. + +## نکتهٔ محتوایی + +متن هر صنف عمداً از پایه متفاوت نوشته شده، نه یک قالب با نام تخصص عوض‌شده. هفت صفحه +با متن مشابه از نگاه موتور جستجو محتوای تکراری است و هر هفت‌تا را پایین می‌کشد. +`LandingRegistryTest` عنوان و `h1` تکراری را رد می‌کند، ولی شباهت متن بدنه را ماشین +تشخیص نمی‌دهد — موقع افزودن صفحهٔ جدید حواست باشد. diff --git a/src/Shared/Controller/LandingController.php b/src/Shared/Controller/LandingController.php new file mode 100644 index 00000000..e662b455 --- /dev/null +++ b/src/Shared/Controller/LandingController.php @@ -0,0 +1,44 @@ +<?php + +namespace App\Shared\Controller; + +use App\Shared\Captcha\AltchaService; +use App\Shared\Landing\LandingRegistry; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +/** + * صفحات فرودِ سئویی روی ریشهٔ دامنه: `/نرم-افزار-مدیریت-مطب` و مانند آن. + * + * کلیدواژه در خودِ نشانی می‌نشیند، که در نتایج فارسی وزن دارد. + */ +final class LandingController extends AbstractController +{ + public function __construct( + private readonly LandingRegistry $registry, + private readonly AltchaService $altcha, + ) {} + + /** + * اولویت منفی لازم است: این الگو هر مسیر تک‌بخشی را می‌گیرد و بدون آن جلوی + * `/robots.txt`، `/health` و هر روت ریشه‌ایِ بعدی را می‌بندد. + */ + #[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, + 'related' => array_values(array_filter(array_map( + fn (string $target) => $this->registry->find($target), + $page->related, + ))), + 'altcha_enabled' => $this->altcha->enabled(), + ]); + } +} diff --git a/src/Shared/Controller/SeoController.php b/src/Shared/Controller/SeoController.php index 96cf0f75..08b9f6f5 100644 --- a/src/Shared/Controller/SeoController.php +++ b/src/Shared/Controller/SeoController.php @@ -2,6 +2,7 @@ namespace App\Shared\Controller; +use App\Shared\Landing\LandingRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -9,6 +10,8 @@ use Symfony\Component\Routing\Attribute\Route; class SeoController extends AbstractController { + public function __construct(private readonly LandingRegistry $landings) {} + #[Route('/robots.txt', name: 'seo_robots', methods: ['GET'])] public function robots(Request $request): Response { @@ -30,18 +33,30 @@ class SeoController extends AbstractController 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"; + . $this->urlNode("{$base}/", 'weekly', '1.0'); + + // اسلاگ‌ها فارسی‌اند و باید percent-encode شوند، وگرنه XML نامعتبر است. + foreach ($this->landings->all() as $page) { + $xml .= $this->urlNode($base . '/' . rawurlencode($page->slug), 'monthly', '0.8'); + } + + $xml .= '</urlset>' . "\n"; return new Response($xml, Response::HTTP_OK, ['Content-Type' => 'application/xml; charset=UTF-8']); } + private function urlNode(string $loc, string $changefreq, string $priority): string + { + return ' <url>' . "\n" + . ' <loc>' . htmlspecialchars($loc, ENT_XML1) . '</loc>' . "\n" + . " <changefreq>{$changefreq}</changefreq>\n" + . " <priority>{$priority}</priority>\n" + . ' </url>' . "\n"; + } + private function isStaging(Request $request): bool { $host = $request->getHost(); diff --git a/src/Shared/Landing/LandingPage.php b/src/Shared/Landing/LandingPage.php new file mode 100644 index 00000000..86a18844 --- /dev/null +++ b/src/Shared/Landing/LandingPage.php @@ -0,0 +1,30 @@ +<?php + +namespace App\Shared\Landing; + +/** + * یک صفحهٔ فرودِ سئویی: همان تمِ صفحهٔ اصلی، با متن و متادیتای مخصوص یک کلیدواژه. + * + * محتوا اینجا داده است نه قالب، چون هفت صفحه یک ساختار دارند و فقط متنشان فرق می‌کند؛ + * هفت فایل Twig یعنی هر تغییر تم هشت بار تکرار شود. + */ +final readonly class LandingPage +{ + /** + * @param string $slug بخش آخر URL، فارسی و با خط تیره + * @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, + ) {} +} diff --git a/src/Shared/Landing/LandingRegistry.php b/src/Shared/Landing/LandingRegistry.php new file mode 100644 index 00000000..f9f0d5af --- /dev/null +++ b/src/Shared/Landing/LandingRegistry.php @@ -0,0 +1,208 @@ +<?php + +namespace App\Shared\Landing; + +/** + * تنها جای تعریف صفحات فرود. + * + * متن هر صفحه عمداً از پایه متفاوت است، نه یک قالب با نام صنف عوض‌شده: هفت صفحه با + * متن یکسان از نگاه موتور جستجو محتوای تکراری است و هر هفت‌تا را پایین می‌کشد. + */ +final class LandingRegistry +{ + public const BEAUTY = 'نرم-افزار-مدیریت-کلینیک-زیبایی'; + public const DENTAL = 'نرم-افزار-مدیریت-کلینیک-دندانپزشکی'; + public const PHYSIOTHERAPY = 'نرم-افزار-مدیریت-کلینیک-فیزیوتراپی'; + public const POLYCLINIC = 'سیستم-جامع-درمانگاهی'; + public const FREE = 'نرم-افزار-رایگان-مدیریت-کلینیک'; + public const OFFICE = 'نرم-افزار-مدیریت-مطب'; + public const CRM = 'crm-کلینیک'; + + /** @var array<string, LandingPage>|null ساخت تنبل؛ هر درخواست حداکثر یک بار می‌سازدش. */ + private ?array $pages = null; + + /** @return array<string, LandingPage> کلید = اسلاگ */ + public function all(): array + { + return $this->pages ??= $this->build(); + } + + public function find(string $slug): ?LandingPage + { + return $this->all()[$slug] ?? null; + } + + /** @return array<string, LandingPage> */ + private function build(): array + { + $pages = [ + new LandingPage( + slug: self::BEAUTY, + metaTitle: 'نرم‌افزار مدیریت کلینیک زیبایی | دوره‌های درمان و انبار مصرفی — کلینیک پرو', + metaDescription: 'نرم‌افزار مدیریت کلینیک زیبایی با مدیریت دوره‌های چندجلسه‌ای لیزر و تزریق، تقویم دستگاه‌ها، انبار مواد مصرفی و پروندهٔ تصویری بیمار. رایگان شروع کنید.', + keywords: 'نرم افزار مدیریت کلینیک زیبایی, نرم افزار کلینیک زیبایی, نرم افزار لیزر موهای زائد, مدیریت دوره درمان زیبایی, نوبت دهی کلینیک زیبایی, انبار مواد مصرفی کلینیک', + h1: 'نرم‌افزار مدیریت کلینیک زیبایی', + lead: 'کار کلینیک زیبایی یک ویزیت نیست؛ یک دوره است. کلینیک پرو هشت جلسهٔ لیزر را از همان جلسهٔ اول می‌سازد، دستگاه را رزرو می‌کند، مصرف مواد را از انبار کم می‌کند و می‌گوید هر بیمار جلسهٔ چندم است.', + features: [ + ['title' => 'دوره‌های چندجلسه‌ای', 'body' => 'برای هر خدمت پروتکل تعریف می‌کنی: چند جلسه، با چه فاصله‌ای. با قطعی‌شدن نوبت، کل دوره ساخته می‌شود ولی فقط جلسهٔ اول وقت می‌گیرد — هشت ماه تقویم قفل نمی‌شود.'], + ['title' => 'تقویم دستگاه‌ها', 'body' => 'لیزر، RF و هایفو هرکدام تقویم خودشان را دارند. دو پزشک نمی‌توانند یک دستگاه را همزمان بگیرند و تداخل سرِ ساعت کشف نمی‌شود.'], + ['title' => 'انبار مواد مصرفی', 'body' => 'ژل، سرسوزن و کارتریج با هر جلسه از موجودی کم می‌شوند و پیش از تمام‌شدن هشدار می‌گیری.'], + ['title' => 'پرسنل و مهارت', 'body' => 'هر جلسه به پرسنل مشخصی سپرده می‌شود و فقط کسی قابل انتخاب است که مهارت آن خدمت را دارد.'], + ['title' => 'صندوق و تخفیف', 'body' => 'پرداخت جزئی، تقسیم بین نقدی و کارت‌خوان، و قوانین تخفیف که خودکار روی فاکتور می‌نشینند.'], + ['title' => 'یادآوری پیامکی', 'body' => 'بیمار جلسهٔ پنجم را فراموش نمی‌کند؛ پیامک یادآوری خودکار می‌رود.'], + ], + faq: [ + ['q' => 'دورهٔ درمان چطور ساخته می‌شود؟', 'a' => 'برای خدمت، پروتکل تعریف می‌کنی: تعداد جلسه و فاصلهٔ بین آن‌ها. با قطعی‌شدن اولین نوبتِ آن خدمت، دوره خودش باز می‌شود و همهٔ جلسات ساخته می‌شوند. جلسهٔ بعدی با انجام‌شدن جلسهٔ قبلی زمان‌بندی می‌شود.'], + ['q' => 'اگر بیمار وسط دوره نوبت دیگری بگیرد چه می‌شود؟', 'a' => 'همان دوره ادامه پیدا می‌کند و دورهٔ موازی ساخته نمی‌شود؛ نوبت جدید به اولین جلسهٔ بدون وقت وصل می‌شود.'], + ['q' => 'مصرف مواد را باید دستی ثبت کنم؟', 'a' => 'پرسنل هنگام ثبت انجام جلسه، کالای مصرف‌شده را انتخاب می‌کند و همان‌جا از انبار کم می‌شود.'], + ['q' => 'برای شروع باید هزینه بدهم؟', 'a' => 'خیر. پلن رایگان برای شروع کافی است و بعداً هر وقت لازم شد ارتقا می‌دهی.'], + ], + related: [self::POLYCLINIC, self::CRM, self::FREE], + ), + new LandingPage( + slug: self::DENTAL, + metaTitle: 'نرم‌افزار مدیریت کلینیک دندان‌پزشکی | پرونده، بیمه و صندوق — کلینیک پرو', + metaDescription: 'نرم‌افزار مدیریت کلینیک دندان‌پزشکی: پروندهٔ درمان هر دندان، سهم بیمهٔ پایه و تکمیلی، تعرفهٔ خدمات، نوبت‌دهی چندواحدی و حساب بیمار. رایگان امتحان کنید.', + keywords: 'نرم افزار مدیریت کلینیک دندانپزشکی, نرم افزار دندانپزشکی, نرم افزار مطب دندانپزشکی, بیمه تکمیلی دندانپزشکی, نوبت دهی دندانپزشکی, پرونده بیمار دندانپزشکی', + h1: 'نرم‌افزار مدیریت کلینیک دندان‌پزشکی', + lead: 'در دندان‌پزشکی، یک بیمار چند درمان همزمان دارد و هر کدام سهم بیمهٔ خودش را. کلینیک پرو تعرفه و سهم بیمهٔ پایه و تکمیلی را خودش حساب می‌کند و می‌گوید بیمار سرِ صندوق دقیقاً چقدر باید بدهد.', + features: [ + ['title' => 'سهم بیمهٔ پایه و تکمیلی', 'body' => 'درصد پوشش هر قرارداد را یک بار وارد می‌کنی؛ از آن به بعد سهم بیمار و سهم بیمه روی هر خدمت خودکار حساب می‌شود.'], + ['title' => 'پیگیری مطالبات', 'body' => 'چقدر از هر بیمه طلبکاری و چقدر وصول شده، به تفکیک بیمار و خدمت.'], + ['title' => 'چند یونیت، چند پزشک', 'body' => 'هر یونیت یک منبع با تقویم مستقل است؛ نوبت هم وقت پزشک را می‌گیرد هم یونیت را.'], + ['title' => 'پروندهٔ کامل بیمار', 'body' => 'سابقهٔ درمان، ضمیمه و تصویر، یادداشت‌ها و همهٔ پرداخت‌ها زیر یک پرونده.'], + ['title' => 'صندوق و پرداخت جزئی', 'body' => 'بیمار بخشی را امروز و بقیه را جلسهٔ بعد می‌دهد؛ مانده در فهرست پرداخت‌ها می‌ماند و گم نمی‌شود.'], + ['title' => 'گزارش مالی روزانه', 'body' => 'آخر روز معلوم است چقدر نقدی، چقدر کارت و چقدر تسویه‌نشده مانده است.'], + ], + faq: [ + ['q' => 'بیمهٔ تکمیلی هم پشتیبانی می‌شود؟', 'a' => 'بله. بیمهٔ پایه و تکمیلی جدا تعریف می‌شوند و سهم هرکدام روی همان خدمت حساب می‌شود.'], + ['q' => 'می‌توانم برای هر خدمت تعرفهٔ متفاوت بگذارم؟', 'a' => 'بله، تعرفه روی خودِ خدمت تعریف می‌شود و در فاکتور همان می‌نشیند.'], + ['q' => 'چند پزشک در یک کلینیک چطور مدیریت می‌شوند؟', 'a' => 'هر پزشک برنامهٔ هفتگی و سرویس‌های خودش را دارد ولی بیمار و پرونده و صندوق مشترک کلینیک است.'], + ['q' => 'نصب لازم دارد؟', 'a' => 'خیر. کلینیک پرو تحت وب است و از مرورگر هر دستگاهی باز می‌شود.'], + ], + related: [self::POLYCLINIC, self::OFFICE, self::FREE], + ), + new LandingPage( + slug: self::PHYSIOTHERAPY, + metaTitle: 'نرم‌افزار مدیریت کلینیک فیزیوتراپی | جلسات درمانی و تخت‌ها — کلینیک پرو', + metaDescription: 'نرم‌افزار مدیریت کلینیک فیزیوتراپی: پکیج جلسات درمانی، تقویم تخت و دستگاه، سپردن جلسه به پرسنل، پیگیری حضور بیمار و حساب بیمه. شروع رایگان.', + keywords: 'نرم افزار مدیریت کلینیک فیزیوتراپی, نرم افزار فیزیوتراپی, مدیریت جلسات فیزیوتراپی, نوبت دهی فیزیوتراپی, نرم افزار مرکز توانبخشی', + h1: 'نرم‌افزار مدیریت کلینیک فیزیوتراپی', + lead: 'فیزیوتراپی یعنی ده جلسه که باید سرِ وقت انجام شوند و بیمار نصفه رهایشان نکند. کلینیک پرو پکیج جلسات را می‌سازد، تخت و دستگاه را رزرو می‌کند و نشان می‌دهد چه کسی چند جلسه عقب است.', + features: [ + ['title' => 'پکیج جلسات درمانی', 'body' => 'ده جلسه را یک‌جا تعریف می‌کنی؛ سیستم پیشرفت هر بیمار را نگه می‌دارد و می‌گوید جلسهٔ چندم است.'], + ['title' => 'تقویم تخت و دستگاه', 'body' => 'هر تخت و هر دستگاه یک منبع است. اگر سه تخت مشابه داری، یک استخر بساز تا سیستم خودش تخت آزاد را بدهد.'], + ['title' => 'سپردن جلسه به پرسنل', 'body' => 'هر جلسه به فیزیوتراپیست مشخصی سپرده می‌شود و او فقط جلسات خودش را در پنلش می‌بیند.'], + ['title' => 'پیگیری غیبت', 'body' => 'بیماری که جلسه‌اش عقب افتاده در فهرست دوره‌ها پیداست و می‌شود برایش پیامک فرستاد.'], + ['title' => 'بیمه و تعرفه', 'body' => 'سهم بیمه روی هر جلسه حساب می‌شود و مطالبات جدا پیگیری می‌شوند.'], + ['title' => 'پرداخت پکیجی یا جلسه‌ای', 'body' => 'هم پرداخت یک‌جای پکیج پشتیبانی می‌شود، هم پرداخت جلسه‌به‌جلسه با مانده‌حساب.'], + ], + faq: [ + ['q' => 'برای هر جلسه باید نوبت جدا ثبت کنم؟', 'a' => 'نه لزوماً. با ثبت انجام هر جلسه، جلسهٔ بعدی زمان‌بندی می‌شود و نوبتش را می‌سازی.'], + ['q' => 'اگر بیمار وسط دوره نیامد چه می‌شود؟', 'a' => 'دوره باز می‌ماند و جلسهٔ انجام‌نشده در فهرست دوره‌ها دیده می‌شود تا پیگیری کنی.'], + ['q' => 'می‌توانم چند تخت را با هم مدیریت کنم؟', 'a' => 'بله. هر تخت یک منبع است و چند تخت هم‌کار را می‌توان در یک استخر گذاشت.'], + ['q' => 'پرسنل به اطلاعات مالی دسترسی دارند؟', 'a' => 'خیر. پنل پرسنل فقط جلسات خودش را نشان می‌دهد.'], + ], + related: [self::POLYCLINIC, self::BEAUTY, self::FREE], + ), + new LandingPage( + slug: self::POLYCLINIC, + metaTitle: 'سیستم جامع درمانگاهی | مدیریت چند پزشک، بخش و صندوق — کلینیک پرو', + metaDescription: 'سیستم جامع درمانگاهی برای مدیریت چند پزشک و چند بخش: نوبت‌دهی مشترک، منابع و اتاق‌ها، پروندهٔ واحد بیمار، صندوق و بیمه، منشی با سطح دسترسی. رایگان شروع کنید.', + keywords: 'سیستم جامع درمانگاهی, نرم افزار درمانگاه, نرم افزار پلی کلینیک, مدیریت درمانگاه, نرم افزار مدیریت مراکز درمانی, نرم افزار چند پزشک', + h1: 'سیستم جامع درمانگاهی', + lead: 'درمانگاه یعنی چند پزشک، چند بخش و یک صندوق. کلینیک پرو همه را زیر یک محیط می‌آورد: تقویم مشترک، پروندهٔ واحد بیمار، منابع بدون تداخل و گزارش مالی یکپارچه.', + features: [ + ['title' => 'چند پزشک، یک تقویم', 'body' => 'هر پزشک تب خودش را دارد و یک نمای «همه» تصویر کل روز درمانگاه را می‌دهد.'], + ['title' => 'پروندهٔ واحد بیمار', 'body' => 'بیماری که این هفته پیش یک پزشک رفته و هفتهٔ بعد پیش پزشک دیگر، یک پرونده دارد نه دو تا.'], + ['title' => 'منابع و اتاق‌ها', 'body' => 'اتاق، تخت و دستگاه تقویم مستقل دارند؛ استخر منابع خودش آزادترین را انتخاب می‌کند.'], + ['title' => 'منشی با سطح دسترسی', 'body' => 'برای هر منشی تعیین می‌کنی روی کدام پزشکان کار کند و چه کارهایی مجاز باشد: ثبت نوبت، لغو، ساخت پرونده، ثبت پرداخت.'], + ['title' => 'صندوق و بیمه', 'body' => 'همهٔ پرداخت‌های درمانگاه از هر پزشکی، یک‌جا؛ به‌علاوهٔ پیگیری مطالبات بیمه.'], + ['title' => 'انبار مشترک', 'body' => 'کالای مصرفی درمانگاه در یک انبار و با هشدار کمبود.'], + ], + faq: [ + ['q' => 'مدیر درمانگاه حتماً باید پزشک باشد؟', 'a' => 'خیر. مدیر غیرپزشک هم می‌تواند صاحب محیط باشد، پزشک دعوت کند و همهٔ تنظیمات را بچیند.'], + ['q' => 'برنامهٔ کاری پزشکان را چه کسی تعیین می‌کند؟', 'a' => 'خود پزشک. تا برنامهٔ هفتگی‌اش را نچیند، در فهرست هست ولی ساعت آزادی ندارد.'], + ['q' => 'تداخل اتاق و دستگاه چطور کنترل می‌شود؟', 'a' => 'با تعریف منبع. نوبت علاوه بر وقت پزشک، وقت منبع را هم اشغال می‌کند.'], + ['q' => 'گزارش مالی تفکیک‌شده دارد؟', 'a' => 'بله، فهرست پرداخت‌ها با وضعیت تسویه و خلاصهٔ مجموع، پرداخت‌شده و تسویه‌نشده.'], + ], + related: [self::DENTAL, self::PHYSIOTHERAPY, self::CRM], + ), + new LandingPage( + slug: self::FREE, + metaTitle: 'نرم‌افزار رایگان مدیریت کلینیک و مطب | پلن رایگان کلینیک پرو', + metaDescription: 'نرم‌افزار رایگان مدیریت کلینیک: نوبت‌دهی آنلاین، پروندهٔ بیمار، سرویس‌ها و صندوق در پلن رایگان کلینیک پرو — بدون هزینهٔ راه‌اندازی و بدون نصب.', + keywords: 'نرم افزار رایگان مدیریت کلینیک, نرم افزار مجانی مدیریت کلینیک, نرم افزار رایگان مطب, نوبت دهی رایگان, نرم افزار پزشکی رایگان', + h1: 'نرم‌افزار رایگان مدیریت کلینیک', + lead: 'برای شروع لازم نیست چیزی بپردازی. پلن رایگان کلینیک پرو نوبت‌دهی، پروندهٔ بیمار، سرویس‌ها و صندوق را دارد؛ هر وقت مطبت بزرگ‌تر شد، ارتقا بده.', + features: [ + ['title' => 'نوبت‌دهی آنلاین', 'body' => 'برنامهٔ هفتگی، تقویم روزانه و ثبت نوبت — همه در پلن رایگان.'], + ['title' => 'پروندهٔ بیمار', 'body' => 'پروندهٔ الکترونیک با سابقهٔ مراجعه، پرداخت و یادداشت.'], + ['title' => 'سرویس و تعرفه', 'body' => 'تعریف خدمات با مدت و تعرفه، و صدور فاکتور.'], + ['title' => 'بدون نصب', 'body' => 'تحت وب است؛ از موبایل و لپ‌تاپ باز می‌شود و نگه‌داری سرور نمی‌خواهد.'], + ['title' => 'ارتقا هر وقت خواستی', 'body' => 'با رشد کار، پلن بالاتر سقف منشی و منابع و امکانات بیشتری می‌دهد.'], + ['title' => 'دادهٔ خودت، مال خودت', 'body' => 'با ارتقا یا تغییر پلن، پرونده‌ها و سابقه سرِ جایشان می‌مانند.'], + ], + faq: [ + ['q' => 'پلن رایگان واقعاً رایگان است؟', 'a' => 'بله، برای شروع هزینه‌ای ندارد. محدودیتش در تعداد منشی و منابع و بعضی امکانات پیشرفته است، نه در کارهای روزمرهٔ مطب.'], + ['q' => 'چه چیزی در پلن رایگان نیست؟', 'a' => 'سقف تعداد منشی و منبع پایین‌تر است و بعضی امکانات مثل ماژول‌های پیشرفتهٔ مالی و پیامکی به پلن بالاتر نیاز دارند. فهرست دقیق در صفحهٔ اشتراک پنل آمده است.'], + ['q' => 'بعداً می‌توانم ارتقا بدهم؟', 'a' => 'بله، هر زمان و بدون از دست رفتن داده.'], + ['q' => 'برای شروع چه چیزی لازم است؟', 'a' => 'فقط یک شمارهٔ موبایل. ثبت‌نام چند دقیقه طول می‌کشد.'], + ], + related: [self::OFFICE, self::BEAUTY, self::POLYCLINIC], + ), + new LandingPage( + slug: self::OFFICE, + metaTitle: 'نرم‌افزار مدیریت مطب | نوبت‌دهی، پرونده و حسابداری — کلینیک پرو', + metaDescription: 'نرم‌افزار مدیریت مطب برای پزشک و منشی: نوبت‌دهی آنلاین، پروندهٔ الکترونیک بیمار، صندوق و گزارش مالی، یادآوری پیامکی. ساده، تحت وب، شروع رایگان.', + keywords: 'نرم افزار مدیریت مطب, نرم افزار مطب, سیستم مدیریت مطب, نوبت دهی مطب, پرونده الکترونیک بیمار, حسابداری مطب', + h1: 'نرم‌افزار مدیریت مطب', + lead: 'مطب یک‌نفره نیازی به سیستم پیچیده ندارد. کلینیک پرو همان چیزی را می‌دهد که هر روز لازم داری: نوبت، پرونده، صندوق — و منشی‌ای که فقط به همان چیزهایی دسترسی دارد که تو تعیین می‌کنی.', + features: [ + ['title' => 'نوبت‌دهی ساده', 'body' => 'برنامهٔ هفتگی را می‌چینی و نوبت‌ها بر اساس مدت هر خدمت خودشان مرتب می‌شوند.'], + ['title' => 'پروندهٔ الکترونیک', 'body' => 'سابقهٔ مراجعه، ضمیمه، یادداشت و پرداخت‌های هر بیمار زیر یک پرونده.'], + ['title' => 'صندوق مطب', 'body' => 'دریافت نقدی، کارت‌خوان و کارت‌به‌کارت، پرداخت جزئی و مانده‌حساب بیمار.'], + ['title' => 'منشی با دسترسی کنترل‌شده', 'body' => 'برای منشی مشخص می‌کنی چه کارهایی مجاز است؛ بقیه برایش اصلاً وجود ندارد.'], + ['title' => 'یادآوری پیامکی', 'body' => 'کاهش نوبت‌های فراموش‌شده با پیامک یادآوری خودکار.'], + ['title' => 'بدون نصب و پشتیبانی سرور', 'body' => 'تحت وب است؛ نه نصب می‌خواهد نه به‌روزرسانی دستی.'], + ], + faq: [ + ['q' => 'برای مطب تک‌پزشکه هم مناسب است؟', 'a' => 'بله. محیط «مطب شخصی» دقیقاً برای همین است و لایه‌های کلینیک را نشان نمی‌دهد.'], + ['q' => 'منشی می‌تواند همه‌چیز را ببیند؟', 'a' => 'فقط چیزهایی که مجوزشان را داده‌ای. ساخت پرونده، لغو نوبت و ثبت پرداخت هرکدام مجوز جداگانه دارند.'], + ['q' => 'اگر بعداً کلینیک بزنم چه می‌شود؟', 'a' => 'همان حساب می‌تواند هم مطب شخصی داشته باشد هم کلینیک، و بین دو محیط جابه‌جا می‌شوی.'], + ['q' => 'هزینهٔ شروع چقدر است؟', 'a' => 'صفر. با پلن رایگان شروع کن و بعداً تصمیم بگیر.'], + ], + related: [self::FREE, self::CRM, self::POLYCLINIC], + ), + new LandingPage( + slug: self::CRM, + metaTitle: 'CRM کلینیک و مطب | پیگیری بیمار، یادآوری و بازگشت مراجع — کلینیک پرو', + metaDescription: 'CRM کلینیک: پروندهٔ کامل ارتباط با بیمار، برچسب‌گذاری، یادآوری پیامکی نوبت، پیگیری دوره‌های ناتمام و گزارش بازگشت مراجعان. بخشی از کلینیک پرو.', + keywords: 'crm کلینیک, crm پزشکی, نرم افزار ارتباط با بیمار, پیگیری بیمار, یادآوری نوبت, بازاریابی کلینیک, باشگاه مشتریان کلینیک', + h1: 'CRM کلینیک‌ها', + lead: 'گرفتن بیمار جدید گران است؛ برنگشتن بیمارِ قبلی گران‌تر. کلینیک پرو سابقهٔ کامل هر مراجع، برچسب‌ها، دوره‌های ناتمام و یادآوری‌ها را نگه می‌دارد تا بدانی چه کسی را باید پیگیری کنی.', + features: [ + ['title' => 'پروندهٔ ارتباط با بیمار', 'body' => 'همهٔ مراجعه‌ها، پرداخت‌ها، یادداشت‌ها و ضمیمه‌های هر بیمار در یک نما.'], + ['title' => 'برچسب‌گذاری', 'body' => 'برچسب‌هایی مثل «پیگیری ویژه» یا «حساسیت دارویی» بساز و بعد با همان‌ها فهرست بیماران را فیلتر کن.'], + ['title' => 'یادآوری خودکار', 'body' => 'پیامک یادآوری نوبت از کیف پول پیامک ارسال می‌شود و نرخ نیامدن را پایین می‌آورد.'], + ['title' => 'دوره‌های ناتمام', 'body' => 'بیمارانی که جلسه‌شان عقب افتاده مشخص‌اند؛ فهرست پیگیری خودش ساخته می‌شود.'], + ['title' => 'بدهکاران', 'body' => 'فیلتر بدهی روی فهرست بیماران می‌گوید چه کسی تسویه نکرده است.'], + ['title' => 'گزارش مالی و مراجعه', 'body' => 'تصویر ماهانه از درآمد، تعداد مراجعه و وضعیت تسویه.'], + ], + faq: [ + ['q' => 'CRM جدا از نرم‌افزار مدیریت است؟', 'a' => 'خیر. همان پرونده و همان دادهٔ نوبت است؛ لازم نیست دو سیستم را هم‌زمان نگه داری.'], + ['q' => 'پیامک‌ها هزینه دارند؟', 'a' => 'ارسال از کیف پول پیامک کم می‌شود که جداگانه شارژ می‌شود.'], + ['q' => 'می‌توانم بیماران را دسته‌بندی کنم؟', 'a' => 'بله، با برچسب‌ها؛ و بعد بر اساس همان‌ها فیلتر و پیگیری کنی.'], + ['q' => 'گزارش بازگشت مراجع دارد؟', 'a' => 'سابقهٔ مراجعهٔ هر بیمار و فهرست دوره‌های ناتمام، همان چیزی است که برای پیگیری بازگشت لازم داری.'], + ], + related: [self::OFFICE, self::POLYCLINIC, self::BEAUTY], + ), + ]; + + return array_column( + array_map(static fn (LandingPage $p): array => [$p->slug, $p], $pages), + 1, + 0, + ); + } +} diff --git a/templates/public/_footer.html.twig b/templates/public/_footer.html.twig new file mode 100644 index 00000000..940b759d --- /dev/null +++ b/templates/public/_footer.html.twig @@ -0,0 +1,86 @@ + <!-- ===================== Footer ===================== --> + <footer class="site-footer" id="contact"> + <div class="wrap"> + <div class="footer-grid"> + <div class="footer-col footer-brand"> + <a class="brand" href="#hero"> + <b>کلینیک پرو</b> + <span class="dot"></span> + </a> + <p>کلینیک پرو را ساختیم تا پزشک‌ها به‌جای درگیری با دفتر و کاغذ، حواس‌شان به کار اصلی‌شان باشد: بیمار.</p> + <div class="foot-socials"> + <a href="#" aria-label="اینستاگرام"> + <svg viewbox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="5" stroke="currentColor" stroke-width="1.8"/><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.8"/><circle cx="17.4" cy="6.6" r="1.2" fill="currentColor"/></svg> + </a> + <a href="#" aria-label="تلگرام"> + <svg viewbox="0 0 24 24" fill="none"><path d="M21 4 3 11l5 2 2 6 3-4 5 4 3-15Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg> + </a> + <a href="#" aria-label="واتساپ"> + <svg viewbox="0 0 24 24" fill="none"><path d="M4 20l1.4-4A8 8 0 1 1 9 18.6L4 20Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg> + </a> + </div> + <!-- نماد اعتماد الکترونیکی — بعد از لود کامل صفحه تزریق می‌شود (footer JS) --> + <div id="enamad-seal" style="margin-top:18px"></div> + </div> + + <div class="footer-col"> + <h4>دسترسی سریع</h4> + <div class="foot-links"> + <a href="#features">امکانات</a> + <a href="#why">چرا کلینیک پرو</a> + <a href="#download" style="display:none">دانلود نرم‌افزار</a> + <a href="#contact">تماس با ما</a> + </div> + </div> + + <div class="footer-col"> + <h4>راهکارها</h4> + <div class="foot-links"> + {% for landing in landing_pages.all() %} + <a href="/{{ landing.slug }}">{{ landing.h1 }}</a> + {% endfor %} + </div> + </div> + + <div class="footer-col"> + <h4>اطلاعات تماس</h4> + <div class="foot-contact"> + <div class="fl"> + <svg viewbox="0 0 24 24" fill="none"><path d="M12 21s-7-5.2-7-11a7 7 0 0 1 14 0c0 5.8-7 11-7 11Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><circle cx="12" cy="10" r="2.6" stroke="currentColor" stroke-width="1.7"/></svg> + <span>یاسوج، مهریان، خیابان شهید ستاره شیرازی، خیابان امام‌زاده احمد، پلاک ۸ شرکت توسعه فناوری اطلاعات حامی</span> + </div> + <div class="fl"> + <svg viewbox="0 0 24 24" fill="none"><path d="M4 6.5C4 5 5 4 6.5 4h2c.6 0 1.1.4 1.3 1l1 3c.2.6 0 1.2-.5 1.6L9 11c1 2 2.5 3.5 4.5 4.5l1.4-1.3c.4-.4 1-.6 1.6-.4l3 1c.6.2 1 .7 1 1.3v2c0 1.5-1 2.5-2.5 2.4C10.5 20 4 13.5 4 6.5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg> + <a href="tel:02191550875" style="font-variant-numeric:tabular-nums" dir="ltr">02191550875</a> + + </div> + <div class="fl"> + <svg viewbox="0 0 24 24" fill="none"><rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="m4 7 8 5 8-5" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg> + <a href="mailto:info@clinic-pro.ir" class="mono" dir="ltr">info@clinic-pro.ir</a> + </div> + </div> + </div> + + <div class="footer-col" style="display:none"> + <h4>دانلود نرم‌افزار</h4> + <div class="foot-badges"> + <a class="store-badge" href="#" style="background:oklch(1 0 0 / 0.08)"> + <svg viewbox="0 0 24 24" fill="currentColor"><path d="M16.4 12.6c0-2.3 1.9-3.4 2-3.5-1.1-1.6-2.8-1.8-3.4-1.8-1.4-.1-2.8.9-3.5.9-.7 0-1.8-.8-3-.8-1.5 0-3 .9-3.7 2.3-1.6 2.8-.4 6.9 1.1 9.2.8 1.1 1.6 2.3 2.8 2.3 1.1 0 1.6-.7 2.9-.7 1.4 0 1.7.7 2.9.7 1.2 0 2-1.1 2.7-2.2.9-1.2 1.2-2.5 1.2-2.5s-2.3-.9-2.3-3.6ZM14.2 5.7c.6-.8 1-1.8.9-2.9-.9 0-2 .6-2.6 1.4-.6.7-1.1 1.7-.9 2.7 1 .1 2-.5 2.6-1.2Z"/></svg> + <span class="sb-t"> + <small>دانلود برای</small> + <b>مک · macOS</b> + </span> + </a> + <a class="store-badge" href="#" style="background:oklch(1 0 0 / 0.08)"> + <svg viewbox="0 0 24 24" fill="currentColor"><path d="M3 5.5 10.5 4.4v7.1H3V5.5Zm0 13L10.5 19.6v-7H3v6Zm8.5 1.3L21 21V12.5h-9.5v7.3Zm0-15.6V11.5H21V3l-9.5 1.2Z"/></svg> + <span class="sb-t"> + <small>دانلود برای</small> + <b>ویندوز</b> + </span> + </a> + </div> + </div> + </div> + </div> + <div class="footer-bottom">© ۲۰۲۴ کلینیک پرو · ساخته‌شده برای پزشکان ایران</div> + </footer> diff --git a/templates/public/_header.html.twig b/templates/public/_header.html.twig new file mode 100644 index 00000000..7c342c31 --- /dev/null +++ b/templates/public/_header.html.twig @@ -0,0 +1,27 @@ + <!-- ===================== Header ===================== --> + <header class="site-header" id="header"> + <div class="wrap nav"> + <a class="brand" href="#hero" aria-label="کلینیک پرو"> + <b>کلینیک پرو</b> + <span class="dot"></span> + <small>مدیریت مطب</small> + </a> + + <nav class="nav-links" id="navLinks"> + <a href="#hero" class="active">خانه</a> + <a href="#features">امکانات</a> + <a href="#why">چرا کلینیک پرو</a> + <a href="#download" style="display:none">دانلود</a> + <a href="#contact">تماس با ما</a> + <a href="#" id="openRegModalNav" class="nav-reg-mobile">ثبت نام</a> + </nav> + + <div class="nav-right"> + <button class="nav-burger nav-toggle" id="navToggle" aria-label="منو"> + <svg viewbox="0 0 24 24" fill="none"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg> + </button> + <button class="btn btn-ghost" id="openRegModal" style="font-size:13px;padding:8px 20px">ثبت نام</button> + <a href="/admin" class="btn btn-blue" style="font-size:13px;padding:8px 20px;text-decoration:none">ورود به پنل</a> + </div> + </div> + </header> diff --git a/templates/public/_hero_art.html.twig b/templates/public/_hero_art.html.twig new file mode 100644 index 00000000..058f86c9 --- /dev/null +++ b/templates/public/_hero_art.html.twig @@ -0,0 +1,80 @@ + <div class="hero-art reveal d1"> + <div class="art-chip chip-1"> + <span class="ci"> + <svg viewbox="0 0 24 24" fill="none"><path d="M12 21s-7-4.3-7-9.5A4 4 0 0 1 12 8a4 4 0 0 1 7 3.5C19 16.7 12 21 12 21Z" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/></svg> + </span> + <div> + <b style="font-variant-numeric:tabular-nums">۹۵٪</b> + <span>رضایت بیماران</span> + </div> + </div> + <div class="art-chip chip-2"> + <span class="ci"> + <svg viewbox="0 0 24 24" fill="none"><path d="M8 7V5m8 2V5M4 9h16M5 7h14v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7Z" stroke="currentColor" stroke-width="1.9"/></svg> + </span> + <div> + <b style="font-variant-numeric:tabular-nums">۳۸</b> + <span>نوبت امروز</span> + </div> + </div> + + <svg viewbox="0 0 600 480" role="img" aria-label="تصویر مدیریت مطب"> + <ellipse cx="300" cy="438" rx="250" ry="30" fill="#e2e9fb"/> + <circle cx="300" cy="244" r="184" fill="#eaeffb"/> + <g fill="#dbe4fb"><ellipse cx="138" cy="150" rx="42" ry="20"/><ellipse cx="476" cy="120" rx="48" ry="22"/><ellipse cx="508" cy="250" rx="30" ry="15"/></g> + + <rect x="196" y="184" width="208" height="152" rx="13" fill="#fff" stroke="#dde3f7" stroke-width="3"/> + <rect x="210" y="198" width="180" height="124" rx="7" fill="#eef2fd"/> + <rect x="223" y="211" width="68" height="9" rx="4.5" fill="#c4cef2"/> + <circle cx="378" cy="216" r="6" fill="#f8b3a6"/> + <path d="M223 272 h24 l9 -22 12 40 10 -28 8 14 h62" fill="none" stroke="#5666e0" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/> + <g fill="#cdd6f4"><rect x="226" y="292" width="14" height="22" rx="3"/><rect x="246" y="284" width="14" height="30" rx="3"/><rect x="266" y="298" width="14" height="16" rx="3"/></g> + <rect x="330" y="288" width="50" height="26" rx="6" fill="#fde3df"/> + <path d="M166 336 h268 l26 28 H140 z" fill="#cfd9f9"/> + <path d="M166 336 h268 l6 6 H160 z" fill="#aab6ee"/> + <rect x="268" y="350" width="64" height="6" rx="3" fill="#9aa8ea"/> + + <g fill="#fff"><ellipse cx="300" cy="190" rx="58" ry="22"/><ellipse cx="258" cy="196" rx="26" ry="15"/><ellipse cx="342" cy="196" rx="26" ry="15"/></g> + + <path d="M276 150 q-20 16 -15 42 l19 -11 z" fill="#3a3a7d"/> + <path d="M324 150 q20 16 15 42 l-19 -11 z" fill="#3a3a7d"/> + <rect x="278" y="70" width="44" height="118" rx="22" fill="#fff" stroke="#dfe4f7" stroke-width="2"/> + <path d="M300 50 a22 22 0 0 1 22 26 h-44 a22 22 0 0 1 22 -26z" fill="#f17a6b"/> + <circle cx="300" cy="106" r="13" fill="#cfe0fb" stroke="#5666e0" stroke-width="3"/> + <g fill="#5666e0"><rect x="295" y="130" width="10" height="30" rx="3"/><rect x="285" y="140" width="30" height="10" rx="3"/></g> + <path d="M289 188 q11 24 11 30 q0 -6 11 -30 z" fill="#f8b3a6"/> + <g stroke="#bcc7f2" stroke-width="4" stroke-linecap="round"><path d="M250 92 h-20"/><path d="M258 112 h-13"/><path d="M350 92 h20"/><path d="M342 112 h13"/></g> + + <g> + <path d="M86 332 h36 l-2 86 a6 6 0 0 1 -12 0 l-4 -52 -4 52 a6 6 0 0 1 -12 0 z" fill="#3a3a7d"/> + <ellipse cx="90" cy="420" rx="12" ry="6" fill="#2b2b5e"/> + <ellipse cx="118" cy="420" rx="12" ry="6" fill="#2b2b5e"/> + <path d="M80 262 q-14 30 -6 58" fill="none" stroke="#fff" stroke-width="15" stroke-linecap="round"/> + <circle cx="76" cy="318" r="7" fill="#f3c6a5"/> + <path d="M78 256 q26 -18 54 0 l8 80 h-70 z" fill="#fff" stroke="#dde3f7" stroke-width="2"/> + <path d="M105 250 l-9 56 9 7 9 -7 -9 -56z" fill="#eef2fd"/> + <path d="M99 254 q-12 28 9 42" fill="none" stroke="#5666e0" stroke-width="2.6"/> + <circle cx="110" cy="298" r="5" fill="#5666e0"/> + <path d="M128 260 q42 -6 66 -54" fill="none" stroke="#fff" stroke-width="15" stroke-linecap="round"/> + <circle cx="196" cy="206" r="7.5" fill="#f3c6a5"/> + <rect x="99" y="232" width="12" height="16" rx="4" fill="#e8b594"/> + <circle cx="105" cy="218" r="22" fill="#f3c6a5"/> + <path d="M83 216 q0 -30 31 -28 q19 1 21 22 q-13 -12 -30 -7 q-15 4 -22 13z" fill="#3a3a7d"/> + </g> + + <g> + <path d="M478 332 h36 l-2 86 a6 6 0 0 1 -12 0 l-4 -52 -4 52 a6 6 0 0 1 -12 0 z" fill="#4f51a8"/> + <ellipse cx="482" cy="420" rx="12" ry="6" fill="#34356f"/> + <ellipse cx="510" cy="420" rx="12" ry="6" fill="#34356f"/> + <path d="M512 258 q26 -16 30 -54" fill="none" stroke="#fff" stroke-width="15" stroke-linecap="round"/> + <circle cx="543" cy="200" r="8" fill="#f3c6a5"/> + <path d="M462 256 q26 -18 54 0 l6 80 h-66 z" fill="#fff" stroke="#dde3f7" stroke-width="2"/> + <path d="M489 250 l-8 56 8 7 8 -7 -8 -56z" fill="#fde3df"/> + <path d="M464 260 q-12 28 -6 54" fill="none" stroke="#fff" stroke-width="14" stroke-linecap="round"/> + <circle cx="460" cy="316" r="7" fill="#f3c6a5"/> + <rect x="483" y="232" width="12" height="16" rx="4" fill="#e8b594"/> + <circle cx="489" cy="218" r="22" fill="#f3c6a5"/> + <path d="M467 220 q-4 -32 28 -32 q24 0 22 30 q-2 14 -6 20 q4 -22 -8 -30 q4 12 -2 18 q-10 -20 -34 -6z" fill="#3a3a7d"/> + </g> + </svg> + </div> diff --git a/templates/public/_page_scripts.html.twig b/templates/public/_page_scripts.html.twig new file mode 100644 index 00000000..eeb8ed06 --- /dev/null +++ b/templates/public/_page_scripts.html.twig @@ -0,0 +1,166 @@ + <script> + (function () { +'use strict'; +var FA = [ +'۰', +'۱', +'۲', +'۳', +'۴', +'۵', +'۶', +'۷', +'۸', +'۹' +]; +function groupFa(n) { +return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, '،').replace(/\d/g, function (d) { +return FA[+ d]; +}); +} + +var header = document.getElementById('header'); +function onScroll() { +header.classList.toggle('scrolled', window.scrollY > 12); +} +window.addEventListener('scroll', onScroll, {passive: true}); +onScroll(); + +var navToggle = document.getElementById('navToggle'); +var navLinks = document.getElementById('navLinks'); +if (navToggle) { +navToggle.addEventListener('click', function () { +navLinks.classList.toggle('open'); +}); +navLinks.addEventListener('click', function (e) { +if (e.target.tagName === 'A') +navLinks.classList.remove('open'); + + + +}); +} + + +var sections = [ +'hero', +'features', +'why', +'download', +'contact' +]; +var links = Array.prototype.slice.call(document.querySelectorAll('.nav-links a')); +function syncActive() { +var y = window.scrollY + 120, +cur = 'hero'; +sections.forEach(function (id) { +var el = document.getElementById(id); +if (el && el.offsetTop <= y) +cur = id; + + + +}); +links.forEach(function (a) { +a.classList.toggle('active', a.getAttribute('href') === '#' + cur); +}); +} +window.addEventListener('scroll', syncActive, {passive: true}); +syncActive(); + +var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; +var reveals = Array.prototype.slice.call(document.querySelectorAll('.reveal')); +function inView(el) { +var r = el.getBoundingClientRect(); +return r.top<(window.innerHeight||document.documentElement.clientHeight)-30 && r.bottom> 0; +} +function reveal(el) { +el.classList.add('in'); +} + +if (reduce) { +reveals.forEach(reveal); +} else { +reveals.forEach(function (el) { +if (inView(el)) +reveal(el); + + + +}); +if ('IntersectionObserver' in window) { +var io = new IntersectionObserver(function (es) { +es.forEach(function (e) { +if (e.isIntersecting) { +reveal(e.target); +io.unobserve(e.target); +} +}); +}, { +threshold: 0.12, +rootMargin: '0px 0px -40px 0px' +}); +reveals.forEach(function (el) { +if (! el.classList.contains('in')) +io.observe(el); + + + +}); +} +var ticking = false; +function sweep() { +if (ticking) +return; + + + +ticking = true; +requestAnimationFrame(function () { +reveals.forEach(function (el) { +if (inView(el)) +reveal(el); + + + +}); +ticking = false; +}); +} +window.addEventListener('scroll', sweep, {passive: true}); +window.addEventListener('resize', sweep, {passive: true}); +setTimeout(function () { +reveals.forEach(reveal); +}, 2800); +} + +// نماد اعتماد الکترونیکی (Enamad) — فقط بعد از لود کامل صفحه تزریق می‌شود تا +// درخواست تصویرِ دامنه‌ی بیرونی با منابع خود سایت رقابت نکند و سرعت لود پایین نیاید. +function mountEnamad() { +var slot = document.getElementById('enamad-seal'); +if (! slot) +return; + + + +var a = document.createElement('a'); +a.setAttribute('referrerpolicy', 'origin'); +a.target = '_blank'; +a.href = 'https://trustseal.enamad.ir/?id=755841&Code=8sNSk0yrUE7EgbY9k5xkqTE0LaeXIaq4'; +var img = document.createElement('img'); +img.setAttribute('referrerpolicy', 'origin'); +img.src = 'https://trustseal.enamad.ir/logo.aspx?id=755841&Code=8sNSk0yrUE7EgbY9k5xkqTE0LaeXIaq4'; +img.alt = 'نماد اعتماد الکترونیکی'; +img.style.cursor = 'pointer'; +img.setAttribute('code', '8sNSk0yrUE7EgbY9k5xkqTE0LaeXIaq4'); +img.loading = 'lazy'; +a.appendChild(img); +slot.appendChild(a); +} +if (document.readyState === 'complete') { +mountEnamad(); +} else { +window.addEventListener('load', mountEnamad); +} +})(); + </script> diff --git a/templates/public/_reg_modal.html.twig b/templates/public/_reg_modal.html.twig new file mode 100644 index 00000000..4d040b9e --- /dev/null +++ b/templates/public/_reg_modal.html.twig @@ -0,0 +1,240 @@ + <!-- ===================== Pre-Registration Modal ===================== --> + <div id="regOverlay" style="display:none;position:fixed;inset:0;background:oklch(0.2 0.05 285 / 0.55);z-index:900;backdrop-filter:blur(4px);overflow-y:auto;padding:24px 16px" onclick="if(event.target===this)closeRegModal()"> + <div style="background:#fff;border-radius:20px;max-width:520px;margin:auto;padding:32px 28px;position:relative;box-shadow:0 24px 60px oklch(0.3 0.1 285 / 0.22)"> + <button onclick="closeRegModal()" aria-label="بستن" style="position:absolute;top:16px;left:20px;background:none;border:none;cursor:pointer;font-size:22px;color:var(--text-2);line-height:1">×</button> + + <h2 style="margin:0 0 6px;font-size:20px;font-weight:800;color:var(--ink)">ثبت نام در کلینیک پرو</h2> + <p style="margin:0 0 24px;font-size:13px;color:var(--text-2)">نوع حساب خود را انتخاب کنید</p> + + <!-- Type Cards --> + <div id="typeCards" style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px"> + + <label class="reg-card" data-type="independent_doctor" style="display:flex;align-items:center;gap:14px;padding:14px 16px;border:2px solid var(--border);border-radius:14px;cursor:pointer;transition:all .2s"> + <input type="radio" name="regType" value="independent_doctor" style="display:none"> + <span style="font-size:26px;flex-shrink:0">🩺</span> + <div> + <div style="font-weight:700;font-size:14px;color:var(--ink)">دکتر هستم</div> + <div style="font-size:12px;color:var(--text-2);margin-top:2px">مطب شخصی دارم، به تنهایی کار می‌کنم</div> + </div> + </label> + + <label class="reg-card" data-type="doctor_with_clinic" style="display:flex;align-items:center;gap:14px;padding:14px 16px;border:2px solid var(--border);border-radius:14px;cursor:pointer;transition:all .2s"> + <input type="radio" name="regType" value="doctor_with_clinic" style="display:none"> + <span style="font-size:26px;flex-shrink:0">🏥</span> + <div> + <div style="font-weight:700;font-size:14px;color:var(--ink)">دکتر هستم و کلینیک دارم</div> + <div style="font-size:12px;color:var(--text-2);margin-top:2px">با چند دکتر در یک کلینیک همکاری می‌کنم</div> + </div> + </label> + + <label class="reg-card" data-type="clinic_manager" style="display:flex;align-items:center;gap:14px;padding:14px 16px;border:2px solid var(--border);border-radius:14px;cursor:pointer;transition:all .2s"> + <input type="radio" name="regType" value="clinic_manager" style="display:none"> + <span style="font-size:26px;flex-shrink:0">🏢</span> + <div> + <div style="font-weight:700;font-size:14px;color:var(--ink)">مدیر کلینیک هستم</div> + <div style="font-size:12px;color:var(--text-2);margin-top:2px">مدیریت کلینیک را دارم، خودم دکتر نیستم</div> + </div> + </label> + </div> + + <!-- Form Fields --> + <div id="regFields" style="display:flex;flex-direction:column;gap:14px"> + <div> + <label style="display:block;font-size:13px;font-weight:600;margin-bottom:5px;color:var(--ink)">نام کامل + <span style="color:var(--coral)">*</span> + </label> + <input id="regName" type="text" placeholder="مثال: دکتر علی احمدی" style="width:100%;border:1.5px solid var(--border);border-radius:10px;padding:10px 14px;font-size:14px;font-family:inherit;outline:none;box-sizing:border-box;color:var(--ink)"/> + </div> + <div> + <label style="display:block;font-size:13px;font-weight:600;margin-bottom:5px;color:var(--ink)">شماره موبایل + <span style="color:var(--coral)">*</span> + </label> + <input id="regMobile" type="tel" placeholder="09xxxxxxxxx" dir="ltr" style="width:100%;border:1.5px solid var(--border);border-radius:10px;padding:10px 14px;font-size:14px;font-family:inherit;outline:none;box-sizing:border-box;color:var(--ink);text-align:right"/> + </div> + <div> + <label style="display:block;font-size:13px;font-weight:600;margin-bottom:5px;color:var(--ink)">توضیحات + <span style="font-weight:400;color:var(--text-2)">(اختیاری)</span> + </label> + <textarea id="regInfo" rows="3" placeholder="تخصص، آدرس، سابقه کاری، ..." style="width:100%;border:1.5px solid var(--border);border-radius:10px;padding:10px 14px;font-size:14px;font-family:inherit;outline:none;box-sizing:border-box;resize:vertical;color:var(--ink)"></textarea> + </div> + </div> + + {% if altcha_enabled %} + <!-- ALTCHA captcha — proof-of-work در پس‌زمینه (بدون تعامل کاربر) --> + {# widget v3: attribute «challenge» (نه challengeurl قدیمی) #} + <altcha-widget id="regAltcha" challenge="/api/v1/altcha/challenge" auto="onload" language="fa" style="display:block;margin-top:16px"></altcha-widget> + {% endif %} + + <!-- Error / Success --> + <div id="regMsg" style="display:none;margin-top:14px;padding:12px 16px;border-radius:10px;font-size:13px"></div> + + <button id="regSubmitBtn" disabled onclick="submitReg()" style="margin-top:20px;width:100%;background:var(--blue);color:#fff;border:none;border-radius:12px;padding:13px;font-size:15px;font-weight:700;cursor:pointer;font-family:inherit;opacity:.45;transition:opacity .2s"> + ارسال درخواست + </button> + <p style="text-align:center;font-size:12px;color:var(--text-2);margin:12px 0 0">پس از بررسی، اطلاعات ورود از طریق SMS ارسال می‌شود</p> + </div> + </div> + + <style> + .reg-card:hover { + border-color: var(--blue) !important; + background: oklch(0.97 0.02 277); + } + .reg-card.selected { + border-color: var(--blue) !important; + background: oklch(0.96 0.03 277); + } + .reg-card.selected div > div:first-child { + color: var(--blue); + } + #regName:focus, + #regMobile:focus, + #regInfo:focus { + border-color: var(--blue); + box-shadow: 0 0 0 3px oklch(0.58 0.185 277 / 0.12); + } + </style> + + <script> + (function () { +var overlay = document.getElementById('regOverlay'); +var submitBtn = document.getElementById('regSubmitBtn'); +var selectedType = null; + +// payload حل‌شده‌ی ALTCHA (یک‌بارمصرف؛ در prod الزامی است) +var regAltchaPayload = ''; +var regAltchaEl = document.getElementById('regAltcha'); +if (regAltchaEl) +regAltchaEl.addEventListener('statechange', function (e) { +if (e.detail && e.detail.state === 'verified') +regAltchaPayload = e.detail.payload; +}); + +function openRegModal() { +overlay.style.display = 'block'; +document.body.style.overflow = 'hidden'; +} +window.closeRegModal = function () { +overlay.style.display = 'none'; +document.body.style.overflow = ''; +}; + +var btn1 = document.getElementById('openRegModal'); +var btn2 = document.getElementById('openRegModal2'); +var btn3 = document.getElementById('openRegModalNav'); +if (btn1) +btn1.addEventListener('click', openRegModal); + + + +if (btn2) +btn2.addEventListener('click', openRegModal); + + + +if (btn3) +btn3.addEventListener('click', function (e) { +e.preventDefault(); +openRegModal(); +}); + + + +document.querySelectorAll('.reg-card').forEach(function (card) { +card.addEventListener('click', function () { +document.querySelectorAll('.reg-card').forEach(function (c) { +c.classList.remove('selected'); +}); +card.classList.add('selected'); +var radio = card.querySelector('input[type=radio]'); +if (radio) +radio.checked = true; + + + +selectedType = card.dataset.type; +submitBtn.disabled = false; +submitBtn.style.opacity = '1'; +}); +}); + +window.submitReg = function () { +var name = document.getElementById('regName').value.trim(); +var mobile = document.getElementById('regMobile').value.trim(); +var info = document.getElementById('regInfo').value.trim(); +var msg = document.getElementById('regMsg'); + +if (! selectedType) +return; + + + +if (! name) { +showMsg('نام را وارد کنید', 'error'); +return; +} +if (! mobile || mobile.length < 10) { +showMsg('شماره موبایل معتبر نیست', 'error'); +return; +} + +submitBtn.disabled = true; +submitBtn.textContent = 'در حال ارسال...'; + +fetch('/api/v1/pre-registration', { +method: 'POST', +headers: { +'Content-Type': 'application/json' +}, +body: JSON.stringify( +{ +type: selectedType, +name: name, +mobile: mobile, +info: info || null, +altcha: regAltchaPayload +} +) +}).then(function (r) { +return r.json(); +}).then(function (data) { +if (data.success) { +showMsg('درخواست شما ثبت شد. پس از بررسی، اطلاعات ورود از طریق SMS ارسال می‌شود.', 'success'); +submitBtn.textContent = 'ارسال شد ✓'; +setTimeout(function () { +closeRegModal(); +}, 2200); +} else { +var errMsg = (data.errors && data.errors[0]) ? data.errors[0].message : 'خطایی رخ داد'; +showMsg(errMsg, 'error'); +submitBtn.disabled = false; +submitBtn.style.opacity = '1'; +submitBtn.textContent = 'ارسال درخواست'; +resetRegAltcha(); +} +}).catch(function () { +showMsg('خطا در اتصال به سرور', 'error'); +submitBtn.disabled = false; +submitBtn.style.opacity = '1'; +submitBtn.textContent = 'ارسال درخواست'; +resetRegAltcha(); +}); +}; + +// challenge یک‌بارمصرف است؛ بعد از خطا یک challenge تازه بگیر. +function resetRegAltcha() { +regAltchaPayload = ''; +if (regAltchaEl && typeof regAltchaEl.reset === 'function') +regAltchaEl.reset(); +} + +function showMsg(text, type) { +var msg = document.getElementById('regMsg'); +msg.textContent = text; +msg.style.display = 'block'; +msg.style.background = type === 'success' ? '#dcfce7' : '#fef2f2'; +msg.style.color = type === 'success' ? '#16a34a' : '#ef4444'; +msg.style.border = type === 'success' ? '1px solid #bbf7d0' : '1px solid #fecaca'; +} +})(); + </script> diff --git a/templates/public/home.html.twig b/templates/public/home.html.twig index 174a3836..b7ff6540 100644 --- a/templates/public/home.html.twig +++ b/templates/public/home.html.twig @@ -70,33 +70,7 @@ </head> <body> - <!-- ===================== Header ===================== --> - <header class="site-header" id="header"> - <div class="wrap nav"> - <a class="brand" href="#hero" aria-label="کلینیک پرو"> - <b>کلینیک پرو</b> - <span class="dot"></span> - <small>مدیریت مطب</small> - </a> - - <nav class="nav-links" id="navLinks"> - <a href="#hero" class="active">خانه</a> - <a href="#features">امکانات</a> - <a href="#why">چرا کلینیک پرو</a> - <a href="#download" style="display:none">دانلود</a> - <a href="#contact">تماس با ما</a> - <a href="#" id="openRegModalNav" class="nav-reg-mobile">ثبت نام</a> - </nav> - - <div class="nav-right"> - <button class="nav-burger nav-toggle" id="navToggle" aria-label="منو"> - <svg viewbox="0 0 24 24" fill="none"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg> - </button> - <button class="btn btn-ghost" id="openRegModal" style="font-size:13px;padding:8px 20px">ثبت نام</button> - <a href="/admin" class="btn btn-blue" style="font-size:13px;padding:8px 20px;text-decoration:none">ورود به پنل</a> - </div> - </div> - </header> + {{ include('public/_header.html.twig') }} <main> <!-- ===================== Hero ===================== --> @@ -111,86 +85,7 @@ </div> </div> - <div class="hero-art reveal d1"> - <div class="art-chip chip-1"> - <span class="ci"> - <svg viewbox="0 0 24 24" fill="none"><path d="M12 21s-7-4.3-7-9.5A4 4 0 0 1 12 8a4 4 0 0 1 7 3.5C19 16.7 12 21 12 21Z" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/></svg> - </span> - <div> - <b style="font-variant-numeric:tabular-nums">۹۵٪</b> - <span>رضایت بیماران</span> - </div> - </div> - <div class="art-chip chip-2"> - <span class="ci"> - <svg viewbox="0 0 24 24" fill="none"><path d="M8 7V5m8 2V5M4 9h16M5 7h14v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7Z" stroke="currentColor" stroke-width="1.9"/></svg> - </span> - <div> - <b style="font-variant-numeric:tabular-nums">۳۸</b> - <span>نوبت امروز</span> - </div> - </div> - - <svg viewbox="0 0 600 480" role="img" aria-label="تصویر مدیریت مطب"> - <ellipse cx="300" cy="438" rx="250" ry="30" fill="#e2e9fb"/> - <circle cx="300" cy="244" r="184" fill="#eaeffb"/> - <g fill="#dbe4fb"><ellipse cx="138" cy="150" rx="42" ry="20"/><ellipse cx="476" cy="120" rx="48" ry="22"/><ellipse cx="508" cy="250" rx="30" ry="15"/></g> - - <rect x="196" y="184" width="208" height="152" rx="13" fill="#fff" stroke="#dde3f7" stroke-width="3"/> - <rect x="210" y="198" width="180" height="124" rx="7" fill="#eef2fd"/> - <rect x="223" y="211" width="68" height="9" rx="4.5" fill="#c4cef2"/> - <circle cx="378" cy="216" r="6" fill="#f8b3a6"/> - <path d="M223 272 h24 l9 -22 12 40 10 -28 8 14 h62" fill="none" stroke="#5666e0" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/> - <g fill="#cdd6f4"><rect x="226" y="292" width="14" height="22" rx="3"/><rect x="246" y="284" width="14" height="30" rx="3"/><rect x="266" y="298" width="14" height="16" rx="3"/></g> - <rect x="330" y="288" width="50" height="26" rx="6" fill="#fde3df"/> - <path d="M166 336 h268 l26 28 H140 z" fill="#cfd9f9"/> - <path d="M166 336 h268 l6 6 H160 z" fill="#aab6ee"/> - <rect x="268" y="350" width="64" height="6" rx="3" fill="#9aa8ea"/> - - <g fill="#fff"><ellipse cx="300" cy="190" rx="58" ry="22"/><ellipse cx="258" cy="196" rx="26" ry="15"/><ellipse cx="342" cy="196" rx="26" ry="15"/></g> - - <path d="M276 150 q-20 16 -15 42 l19 -11 z" fill="#3a3a7d"/> - <path d="M324 150 q20 16 15 42 l-19 -11 z" fill="#3a3a7d"/> - <rect x="278" y="70" width="44" height="118" rx="22" fill="#fff" stroke="#dfe4f7" stroke-width="2"/> - <path d="M300 50 a22 22 0 0 1 22 26 h-44 a22 22 0 0 1 22 -26z" fill="#f17a6b"/> - <circle cx="300" cy="106" r="13" fill="#cfe0fb" stroke="#5666e0" stroke-width="3"/> - <g fill="#5666e0"><rect x="295" y="130" width="10" height="30" rx="3"/><rect x="285" y="140" width="30" height="10" rx="3"/></g> - <path d="M289 188 q11 24 11 30 q0 -6 11 -30 z" fill="#f8b3a6"/> - <g stroke="#bcc7f2" stroke-width="4" stroke-linecap="round"><path d="M250 92 h-20"/><path d="M258 112 h-13"/><path d="M350 92 h20"/><path d="M342 112 h13"/></g> - - <g> - <path d="M86 332 h36 l-2 86 a6 6 0 0 1 -12 0 l-4 -52 -4 52 a6 6 0 0 1 -12 0 z" fill="#3a3a7d"/> - <ellipse cx="90" cy="420" rx="12" ry="6" fill="#2b2b5e"/> - <ellipse cx="118" cy="420" rx="12" ry="6" fill="#2b2b5e"/> - <path d="M80 262 q-14 30 -6 58" fill="none" stroke="#fff" stroke-width="15" stroke-linecap="round"/> - <circle cx="76" cy="318" r="7" fill="#f3c6a5"/> - <path d="M78 256 q26 -18 54 0 l8 80 h-70 z" fill="#fff" stroke="#dde3f7" stroke-width="2"/> - <path d="M105 250 l-9 56 9 7 9 -7 -9 -56z" fill="#eef2fd"/> - <path d="M99 254 q-12 28 9 42" fill="none" stroke="#5666e0" stroke-width="2.6"/> - <circle cx="110" cy="298" r="5" fill="#5666e0"/> - <path d="M128 260 q42 -6 66 -54" fill="none" stroke="#fff" stroke-width="15" stroke-linecap="round"/> - <circle cx="196" cy="206" r="7.5" fill="#f3c6a5"/> - <rect x="99" y="232" width="12" height="16" rx="4" fill="#e8b594"/> - <circle cx="105" cy="218" r="22" fill="#f3c6a5"/> - <path d="M83 216 q0 -30 31 -28 q19 1 21 22 q-13 -12 -30 -7 q-15 4 -22 13z" fill="#3a3a7d"/> - </g> - - <g> - <path d="M478 332 h36 l-2 86 a6 6 0 0 1 -12 0 l-4 -52 -4 52 a6 6 0 0 1 -12 0 z" fill="#4f51a8"/> - <ellipse cx="482" cy="420" rx="12" ry="6" fill="#34356f"/> - <ellipse cx="510" cy="420" rx="12" ry="6" fill="#34356f"/> - <path d="M512 258 q26 -16 30 -54" fill="none" stroke="#fff" stroke-width="15" stroke-linecap="round"/> - <circle cx="543" cy="200" r="8" fill="#f3c6a5"/> - <path d="M462 256 q26 -18 54 0 l6 80 h-66 z" fill="#fff" stroke="#dde3f7" stroke-width="2"/> - <path d="M489 250 l-8 56 8 7 8 -7 -8 -56z" fill="#fde3df"/> - <path d="M464 260 q-12 28 -6 54" fill="none" stroke="#fff" stroke-width="14" stroke-linecap="round"/> - <circle cx="460" cy="316" r="7" fill="#f3c6a5"/> - <rect x="483" y="232" width="12" height="16" rx="4" fill="#e8b594"/> - <circle cx="489" cy="218" r="22" fill="#f3c6a5"/> - <path d="M467 220 q-4 -32 28 -32 q24 0 22 30 q-2 14 -6 20 q4 -22 -8 -30 q4 12 -2 18 q-10 -20 -34 -6z" fill="#3a3a7d"/> - </g> - </svg> - </div> + {{ include('public/_hero_art.html.twig') }} </div> </section> @@ -500,493 +395,13 @@ </div> </div> </section> - <!-- ===================== Pre-Registration Modal ===================== --> - <div id="regOverlay" style="display:none;position:fixed;inset:0;background:oklch(0.2 0.05 285 / 0.55);z-index:900;backdrop-filter:blur(4px);overflow-y:auto;padding:24px 16px" onclick="if(event.target===this)closeRegModal()"> - <div style="background:#fff;border-radius:20px;max-width:520px;margin:auto;padding:32px 28px;position:relative;box-shadow:0 24px 60px oklch(0.3 0.1 285 / 0.22)"> - <button onclick="closeRegModal()" aria-label="بستن" style="position:absolute;top:16px;left:20px;background:none;border:none;cursor:pointer;font-size:22px;color:var(--text-2);line-height:1">×</button> - - <h2 style="margin:0 0 6px;font-size:20px;font-weight:800;color:var(--ink)">ثبت نام در کلینیک پرو</h2> - <p style="margin:0 0 24px;font-size:13px;color:var(--text-2)">نوع حساب خود را انتخاب کنید</p> - - <!-- Type Cards --> - <div id="typeCards" style="display:flex;flex-direction:column;gap:10px;margin-bottom:22px"> - - <label class="reg-card" data-type="independent_doctor" style="display:flex;align-items:center;gap:14px;padding:14px 16px;border:2px solid var(--border);border-radius:14px;cursor:pointer;transition:all .2s"> - <input type="radio" name="regType" value="independent_doctor" style="display:none"> - <span style="font-size:26px;flex-shrink:0">🩺</span> - <div> - <div style="font-weight:700;font-size:14px;color:var(--ink)">دکتر هستم</div> - <div style="font-size:12px;color:var(--text-2);margin-top:2px">مطب شخصی دارم، به تنهایی کار می‌کنم</div> - </div> - </label> - - <label class="reg-card" data-type="doctor_with_clinic" style="display:flex;align-items:center;gap:14px;padding:14px 16px;border:2px solid var(--border);border-radius:14px;cursor:pointer;transition:all .2s"> - <input type="radio" name="regType" value="doctor_with_clinic" style="display:none"> - <span style="font-size:26px;flex-shrink:0">🏥</span> - <div> - <div style="font-weight:700;font-size:14px;color:var(--ink)">دکتر هستم و کلینیک دارم</div> - <div style="font-size:12px;color:var(--text-2);margin-top:2px">با چند دکتر در یک کلینیک همکاری می‌کنم</div> - </div> - </label> - - <label class="reg-card" data-type="clinic_manager" style="display:flex;align-items:center;gap:14px;padding:14px 16px;border:2px solid var(--border);border-radius:14px;cursor:pointer;transition:all .2s"> - <input type="radio" name="regType" value="clinic_manager" style="display:none"> - <span style="font-size:26px;flex-shrink:0">🏢</span> - <div> - <div style="font-weight:700;font-size:14px;color:var(--ink)">مدیر کلینیک هستم</div> - <div style="font-size:12px;color:var(--text-2);margin-top:2px">مدیریت کلینیک را دارم، خودم دکتر نیستم</div> - </div> - </label> - </div> - - <!-- Form Fields --> - <div id="regFields" style="display:flex;flex-direction:column;gap:14px"> - <div> - <label style="display:block;font-size:13px;font-weight:600;margin-bottom:5px;color:var(--ink)">نام کامل - <span style="color:var(--coral)">*</span> - </label> - <input id="regName" type="text" placeholder="مثال: دکتر علی احمدی" style="width:100%;border:1.5px solid var(--border);border-radius:10px;padding:10px 14px;font-size:14px;font-family:inherit;outline:none;box-sizing:border-box;color:var(--ink)"/> - </div> - <div> - <label style="display:block;font-size:13px;font-weight:600;margin-bottom:5px;color:var(--ink)">شماره موبایل - <span style="color:var(--coral)">*</span> - </label> - <input id="regMobile" type="tel" placeholder="09xxxxxxxxx" dir="ltr" style="width:100%;border:1.5px solid var(--border);border-radius:10px;padding:10px 14px;font-size:14px;font-family:inherit;outline:none;box-sizing:border-box;color:var(--ink);text-align:right"/> - </div> - <div> - <label style="display:block;font-size:13px;font-weight:600;margin-bottom:5px;color:var(--ink)">توضیحات - <span style="font-weight:400;color:var(--text-2)">(اختیاری)</span> - </label> - <textarea id="regInfo" rows="3" placeholder="تخصص، آدرس، سابقه کاری، ..." style="width:100%;border:1.5px solid var(--border);border-radius:10px;padding:10px 14px;font-size:14px;font-family:inherit;outline:none;box-sizing:border-box;resize:vertical;color:var(--ink)"></textarea> - </div> - </div> - - {% if altcha_enabled %} - <!-- ALTCHA captcha — proof-of-work در پس‌زمینه (بدون تعامل کاربر) --> - {# widget v3: attribute «challenge» (نه challengeurl قدیمی) #} - <altcha-widget id="regAltcha" challenge="/api/v1/altcha/challenge" auto="onload" language="fa" style="display:block;margin-top:16px"></altcha-widget> - {% endif %} - - <!-- Error / Success --> - <div id="regMsg" style="display:none;margin-top:14px;padding:12px 16px;border-radius:10px;font-size:13px"></div> - - <button id="regSubmitBtn" disabled onclick="submitReg()" style="margin-top:20px;width:100%;background:var(--blue);color:#fff;border:none;border-radius:12px;padding:13px;font-size:15px;font-weight:700;cursor:pointer;font-family:inherit;opacity:.45;transition:opacity .2s"> - ارسال درخواست - </button> - <p style="text-align:center;font-size:12px;color:var(--text-2);margin:12px 0 0">پس از بررسی، اطلاعات ورود از طریق SMS ارسال می‌شود</p> - </div> - </div> - - <style> - .reg-card:hover { - border-color: var(--blue) !important; - background: oklch(0.97 0.02 277); - } - .reg-card.selected { - border-color: var(--blue) !important; - background: oklch(0.96 0.03 277); - } - .reg-card.selected div > div:first-child { - color: var(--blue); - } - #regName:focus, - #regMobile:focus, - #regInfo:focus { - border-color: var(--blue); - box-shadow: 0 0 0 3px oklch(0.58 0.185 277 / 0.12); - } - </style> - - <script> - (function () { -var overlay = document.getElementById('regOverlay'); -var submitBtn = document.getElementById('regSubmitBtn'); -var selectedType = null; - -// payload حل‌شده‌ی ALTCHA (یک‌بارمصرف؛ در prod الزامی است) -var regAltchaPayload = ''; -var regAltchaEl = document.getElementById('regAltcha'); -if (regAltchaEl) -regAltchaEl.addEventListener('statechange', function (e) { -if (e.detail && e.detail.state === 'verified') -regAltchaPayload = e.detail.payload; -}); - -function openRegModal() { -overlay.style.display = 'block'; -document.body.style.overflow = 'hidden'; -} -window.closeRegModal = function () { -overlay.style.display = 'none'; -document.body.style.overflow = ''; -}; - -var btn1 = document.getElementById('openRegModal'); -var btn2 = document.getElementById('openRegModal2'); -var btn3 = document.getElementById('openRegModalNav'); -if (btn1) -btn1.addEventListener('click', openRegModal); - - - -if (btn2) -btn2.addEventListener('click', openRegModal); - - - -if (btn3) -btn3.addEventListener('click', function (e) { -e.preventDefault(); -openRegModal(); -}); - - - -document.querySelectorAll('.reg-card').forEach(function (card) { -card.addEventListener('click', function () { -document.querySelectorAll('.reg-card').forEach(function (c) { -c.classList.remove('selected'); -}); -card.classList.add('selected'); -var radio = card.querySelector('input[type=radio]'); -if (radio) -radio.checked = true; - - - -selectedType = card.dataset.type; -submitBtn.disabled = false; -submitBtn.style.opacity = '1'; -}); -}); - -window.submitReg = function () { -var name = document.getElementById('regName').value.trim(); -var mobile = document.getElementById('regMobile').value.trim(); -var info = document.getElementById('regInfo').value.trim(); -var msg = document.getElementById('regMsg'); - -if (! selectedType) -return; - - - -if (! name) { -showMsg('نام را وارد کنید', 'error'); -return; -} -if (! mobile || mobile.length < 10) { -showMsg('شماره موبایل معتبر نیست', 'error'); -return; -} - -submitBtn.disabled = true; -submitBtn.textContent = 'در حال ارسال...'; - -fetch('/api/v1/pre-registration', { -method: 'POST', -headers: { -'Content-Type': 'application/json' -}, -body: JSON.stringify( -{ -type: selectedType, -name: name, -mobile: mobile, -info: info || null, -altcha: regAltchaPayload -} -) -}).then(function (r) { -return r.json(); -}).then(function (data) { -if (data.success) { -showMsg('درخواست شما ثبت شد. پس از بررسی، اطلاعات ورود از طریق SMS ارسال می‌شود.', 'success'); -submitBtn.textContent = 'ارسال شد ✓'; -setTimeout(function () { -closeRegModal(); -}, 2200); -} else { -var errMsg = (data.errors && data.errors[0]) ? data.errors[0].message : 'خطایی رخ داد'; -showMsg(errMsg, 'error'); -submitBtn.disabled = false; -submitBtn.style.opacity = '1'; -submitBtn.textContent = 'ارسال درخواست'; -resetRegAltcha(); -} -}).catch(function () { -showMsg('خطا در اتصال به سرور', 'error'); -submitBtn.disabled = false; -submitBtn.style.opacity = '1'; -submitBtn.textContent = 'ارسال درخواست'; -resetRegAltcha(); -}); -}; - -// challenge یک‌بارمصرف است؛ بعد از خطا یک challenge تازه بگیر. -function resetRegAltcha() { -regAltchaPayload = ''; -if (regAltchaEl && typeof regAltchaEl.reset === 'function') -regAltchaEl.reset(); -} - -function showMsg(text, type) { -var msg = document.getElementById('regMsg'); -msg.textContent = text; -msg.style.display = 'block'; -msg.style.background = type === 'success' ? '#dcfce7' : '#fef2f2'; -msg.style.color = type === 'success' ? '#16a34a' : '#ef4444'; -msg.style.border = type === 'success' ? '1px solid #bbf7d0' : '1px solid #fecaca'; -} -})(); - </script> + {{ include('public/_reg_modal.html.twig') }} </main> - <!-- ===================== Footer ===================== --> - <footer class="site-footer" id="contact"> - <div class="wrap"> - <div class="footer-grid"> - <div class="footer-col footer-brand"> - <a class="brand" href="#hero"> - <b>کلینیک پرو</b> - <span class="dot"></span> - </a> - <p>کلینیک پرو را ساختیم تا پزشک‌ها به‌جای درگیری با دفتر و کاغذ، حواس‌شان به کار اصلی‌شان باشد: بیمار.</p> - <div class="foot-socials"> - <a href="#" aria-label="اینستاگرام"> - <svg viewbox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="5" stroke="currentColor" stroke-width="1.8"/><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.8"/><circle cx="17.4" cy="6.6" r="1.2" fill="currentColor"/></svg> - </a> - <a href="#" aria-label="تلگرام"> - <svg viewbox="0 0 24 24" fill="none"><path d="M21 4 3 11l5 2 2 6 3-4 5 4 3-15Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg> - </a> - <a href="#" aria-label="واتساپ"> - <svg viewbox="0 0 24 24" fill="none"><path d="M4 20l1.4-4A8 8 0 1 1 9 18.6L4 20Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg> - </a> - </div> - <!-- نماد اعتماد الکترونیکی — بعد از لود کامل صفحه تزریق می‌شود (footer JS) --> - <div id="enamad-seal" style="margin-top:18px"></div> - </div> + {{ include('public/_footer.html.twig') }} - <div class="footer-col"> - <h4>دسترسی سریع</h4> - <div class="foot-links"> - <a href="#features">امکانات</a> - <a href="#why">چرا کلینیک پرو</a> - <a href="#download" style="display:none">دانلود نرم‌افزار</a> - <a href="#contact">تماس با ما</a> - </div> - </div> - - <div class="footer-col"> - <h4>اطلاعات تماس</h4> - <div class="foot-contact"> - <div class="fl"> - <svg viewbox="0 0 24 24" fill="none"><path d="M12 21s-7-5.2-7-11a7 7 0 0 1 14 0c0 5.8-7 11-7 11Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><circle cx="12" cy="10" r="2.6" stroke="currentColor" stroke-width="1.7"/></svg> - <span>یاسوج، مهریان، خیابان شهید ستاره شیرازی، خیابان امام‌زاده احمد، پلاک ۸ شرکت توسعه فناوری اطلاعات حامی</span> - </div> - <div class="fl"> - <svg viewbox="0 0 24 24" fill="none"><path d="M4 6.5C4 5 5 4 6.5 4h2c.6 0 1.1.4 1.3 1l1 3c.2.6 0 1.2-.5 1.6L9 11c1 2 2.5 3.5 4.5 4.5l1.4-1.3c.4-.4 1-.6 1.6-.4l3 1c.6.2 1 .7 1 1.3v2c0 1.5-1 2.5-2.5 2.4C10.5 20 4 13.5 4 6.5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg> - <a href="tel:02191550875" style="font-variant-numeric:tabular-nums" dir="ltr">02191550875</a> - - </div> - <div class="fl"> - <svg viewbox="0 0 24 24" fill="none"><rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="m4 7 8 5 8-5" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg> - <a href="mailto:info@clinic-pro.ir" class="mono" dir="ltr">info@clinic-pro.ir</a> - </div> - </div> - </div> - - <div class="footer-col" style="display:none"> - <h4>دانلود نرم‌افزار</h4> - <div class="foot-badges"> - <a class="store-badge" href="#" style="background:oklch(1 0 0 / 0.08)"> - <svg viewbox="0 0 24 24" fill="currentColor"><path d="M16.4 12.6c0-2.3 1.9-3.4 2-3.5-1.1-1.6-2.8-1.8-3.4-1.8-1.4-.1-2.8.9-3.5.9-.7 0-1.8-.8-3-.8-1.5 0-3 .9-3.7 2.3-1.6 2.8-.4 6.9 1.1 9.2.8 1.1 1.6 2.3 2.8 2.3 1.1 0 1.6-.7 2.9-.7 1.4 0 1.7.7 2.9.7 1.2 0 2-1.1 2.7-2.2.9-1.2 1.2-2.5 1.2-2.5s-2.3-.9-2.3-3.6ZM14.2 5.7c.6-.8 1-1.8.9-2.9-.9 0-2 .6-2.6 1.4-.6.7-1.1 1.7-.9 2.7 1 .1 2-.5 2.6-1.2Z"/></svg> - <span class="sb-t"> - <small>دانلود برای</small> - <b>مک · macOS</b> - </span> - </a> - <a class="store-badge" href="#" style="background:oklch(1 0 0 / 0.08)"> - <svg viewbox="0 0 24 24" fill="currentColor"><path d="M3 5.5 10.5 4.4v7.1H3V5.5Zm0 13L10.5 19.6v-7H3v6Zm8.5 1.3L21 21V12.5h-9.5v7.3Zm0-15.6V11.5H21V3l-9.5 1.2Z"/></svg> - <span class="sb-t"> - <small>دانلود برای</small> - <b>ویندوز</b> - </span> - </a> - </div> - </div> - </div> - </div> - <div class="footer-bottom">© ۲۰۲۴ کلینیک پرو · ساخته‌شده برای پزشکان ایران</div> - </footer> - - <script> - (function () { -'use strict'; -var FA = [ -'۰', -'۱', -'۲', -'۳', -'۴', -'۵', -'۶', -'۷', -'۸', -'۹' -]; -function groupFa(n) { -return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, '،').replace(/\d/g, function (d) { -return FA[+ d]; -}); -} - -var header = document.getElementById('header'); -function onScroll() { -header.classList.toggle('scrolled', window.scrollY > 12); -} -window.addEventListener('scroll', onScroll, {passive: true}); -onScroll(); - -var navToggle = document.getElementById('navToggle'); -var navLinks = document.getElementById('navLinks'); -if (navToggle) { -navToggle.addEventListener('click', function () { -navLinks.classList.toggle('open'); -}); -navLinks.addEventListener('click', function (e) { -if (e.target.tagName === 'A') -navLinks.classList.remove('open'); - - - -}); -} - - -var sections = [ -'hero', -'features', -'why', -'download', -'contact' -]; -var links = Array.prototype.slice.call(document.querySelectorAll('.nav-links a')); -function syncActive() { -var y = window.scrollY + 120, -cur = 'hero'; -sections.forEach(function (id) { -var el = document.getElementById(id); -if (el && el.offsetTop <= y) -cur = id; - - - -}); -links.forEach(function (a) { -a.classList.toggle('active', a.getAttribute('href') === '#' + cur); -}); -} -window.addEventListener('scroll', syncActive, {passive: true}); -syncActive(); - -var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; -var reveals = Array.prototype.slice.call(document.querySelectorAll('.reveal')); -function inView(el) { -var r = el.getBoundingClientRect(); -return r.top<(window.innerHeight||document.documentElement.clientHeight)-30 && r.bottom> 0; -} -function reveal(el) { -el.classList.add('in'); -} - -if (reduce) { -reveals.forEach(reveal); -} else { -reveals.forEach(function (el) { -if (inView(el)) -reveal(el); - - - -}); -if ('IntersectionObserver' in window) { -var io = new IntersectionObserver(function (es) { -es.forEach(function (e) { -if (e.isIntersecting) { -reveal(e.target); -io.unobserve(e.target); -} -}); -}, { -threshold: 0.12, -rootMargin: '0px 0px -40px 0px' -}); -reveals.forEach(function (el) { -if (! el.classList.contains('in')) -io.observe(el); - - - -}); -} -var ticking = false; -function sweep() { -if (ticking) -return; - - - -ticking = true; -requestAnimationFrame(function () { -reveals.forEach(function (el) { -if (inView(el)) -reveal(el); - - - -}); -ticking = false; -}); -} -window.addEventListener('scroll', sweep, {passive: true}); -window.addEventListener('resize', sweep, {passive: true}); -setTimeout(function () { -reveals.forEach(reveal); -}, 2800); -} - -// نماد اعتماد الکترونیکی (Enamad) — فقط بعد از لود کامل صفحه تزریق می‌شود تا -// درخواست تصویرِ دامنه‌ی بیرونی با منابع خود سایت رقابت نکند و سرعت لود پایین نیاید. -function mountEnamad() { -var slot = document.getElementById('enamad-seal'); -if (! slot) -return; - - - -var a = document.createElement('a'); -a.setAttribute('referrerpolicy', 'origin'); -a.target = '_blank'; -a.href = 'https://trustseal.enamad.ir/?id=755841&Code=8sNSk0yrUE7EgbY9k5xkqTE0LaeXIaq4'; -var img = document.createElement('img'); -img.setAttribute('referrerpolicy', 'origin'); -img.src = 'https://trustseal.enamad.ir/logo.aspx?id=755841&Code=8sNSk0yrUE7EgbY9k5xkqTE0LaeXIaq4'; -img.alt = 'نماد اعتماد الکترونیکی'; -img.style.cursor = 'pointer'; -img.setAttribute('code', '8sNSk0yrUE7EgbY9k5xkqTE0LaeXIaq4'); -img.loading = 'lazy'; -a.appendChild(img); -slot.appendChild(a); -} -if (document.readyState === 'complete') { -mountEnamad(); -} else { -window.addEventListener('load', mountEnamad); -} -})(); - </script> + {{ include('public/_page_scripts.html.twig') }} {{ encore_entry_script_tags('home') }} </body> diff --git a/templates/public/landing.html.twig b/templates/public/landing.html.twig new file mode 100644 index 00000000..c1d12a1c --- /dev/null +++ b/templates/public/landing.html.twig @@ -0,0 +1,146 @@ +{# قالب مشترک صفحات فرود. تم دقیقاً همان صفحهٔ اصلی است — همان entry وبپک، همان هدر و + فوتر و مودال. فقط متن و متادیتا از LandingPage می‌آید. #} +{% set base = app.request.schemeAndHttpHost %} +{% set url = base ~ '/' ~ page.slug %} +<!DOCTYPE html> +<html lang="fa" dir="rtl"> + <head> + <meta charset="UTF-8"/> + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> + <title>{{ page.metaTitle }} + + + + + + + + + + + + + + + + + + + + + {# JSON-LD با json_encode ساخته می‌شود نه رشتهٔ دستی: متن فارسی نقل‌قول و کاراکتر + کنترلی دارد و JSONِ دست‌ساز در Twig خیلی راحت نامعتبر می‌شود. #} + + + {{ encore_entry_link_tags('home') }} + + + + {{ include('public/_header.html.twig') }} + +
+
+
+
+

{{ page.h1 }}

+

{{ page.lead }}

+
+ {# همان idهایی که اسکریپت مودال به آن‌ها bind می‌شود. #} + + درخواست دمو +
+
+ {{ include('public/_hero_art.html.twig') }} +
+
+ +
+
+ {% for feature in page.features %} +
+

{{ feature.title }}

+

{{ feature.body }}

+
+ {% endfor %} +
+
+ + {% if page.faq is not empty %} +
+
+

پرسش‌های متداول

+
+ {% for item in page.faq %} +
+

{{ item.q }}

+

{{ item.a }}

+
+ {% endfor %} +
+
+
+ {% endif %} + + {% if related is not empty %} + + {% endif %} + + {{ include('public/_reg_modal.html.twig') }} +
+ + {{ include('public/_footer.html.twig') }} + {{ include('public/_page_scripts.html.twig') }} + {{ encore_entry_script_tags('home') }} + + diff --git a/tests/Shared/LandingPageTest.php b/tests/Shared/LandingPageTest.php new file mode 100644 index 00000000..5dee75df --- /dev/null +++ b/tests/Shared/LandingPageTest.php @@ -0,0 +1,132 @@ +client = static::createClient(); + $this->registry = new LandingRegistry(); + } + + private function html(string $path): string + { + $this->client->request('GET', $path); + + return $this->client->getResponse()->getContent(); + } + + public function testEveryLandingRenders(): void + { + foreach ($this->registry->all() as $slug => $page) { + $html = $this->html('/' . $slug); + + self::assertSame(200, $this->client->getResponse()->getStatusCode(), "صفحهٔ «{$slug}» باز نشد"); + self::assertStringContainsString('

' . $page->h1 . '

', $html); + self::assertStringContainsString('' . $page->metaTitle . '', $html); + self::assertStringContainsString('content="' . $page->metaDescription . '"', $html); + } + } + + public function testCanonicalPointsAtTheSamePage(): void + { + foreach (array_keys($this->registry->all()) as $slug) { + $html = $this->html('/' . $slug); + + self::assertMatchesRegularExpression( + '~~u', + $html, + "canonical صفحهٔ «{$slug}» به خودش اشاره نمی‌کند", + ); + } + } + + public function testUnknownSlugIsNotFound(): void + { + $this->client->request('GET', '/یک-اسلاگ-که-وجود-ندارد'); + + self::assertSame(404, $this->client->getResponse()->getStatusCode()); + } + + /** روت catch-all نباید صفحهٔ اصلی را بخورد. */ + public function testHomePageStillWorks(): void + { + $html = $this->html('/'); + + self::assertSame(200, $this->client->getResponse()->getStatusCode()); + self::assertStringContainsString('هوشمند، سریع و', $html); + } + + public function testExistingRoutesAreNotShadowed(): void + { + foreach (['/sitemap.xml', '/health'] as $path) { + $this->client->request('GET', $path); + self::assertSame(200, $this->client->getResponse()->getStatusCode(), "{$path} گرفته شد"); + } + } + + public function testSitemapListsHomeAndEveryLanding(): void + { + $xml = $this->html('/sitemap.xml'); + $doc = new \SimpleXMLElement($xml); + + self::assertCount(8, $doc->url, 'sitemap باید خانه و هفت لندینگ را داشته باشد'); + + foreach (array_keys($this->registry->all()) as $slug) { + self::assertStringContainsString(rawurlencode($slug), $xml, "«{$slug}» در sitemap نیست"); + } + } + + /** بدون لینک داخلی، صفحات فقط از sitemap دیده می‌شوند. */ + public function testFooterLinksToEveryLandingOnEveryPage(): void + { + foreach (['/', '/' . LandingRegistry::CRM] as $path) { + $html = $this->html($path); + + foreach (array_keys($this->registry->all()) as $slug) { + self::assertStringContainsString('href="/' . $slug . '"', $html, "لینک «{$slug}» در {$path} نیست"); + } + } + } + + public function testStructuredDataIsValidJson(): void + { + foreach (array_keys($this->registry->all()) as $slug) { + $html = $this->html('/' . $slug); + + preg_match('~~s', $html, $m); + self::assertNotEmpty($m, "صفحهٔ «{$slug}» بلاک JSON-LD ندارد"); + + $data = json_decode(html_entity_decode($m[1]), true, flags: JSON_THROW_ON_ERROR); + $types = array_column($data['@graph'], '@type'); + + self::assertContains('SoftwareApplication', $types); + self::assertContains('BreadcrumbList', $types); + self::assertContains('FAQPage', $types); + } + } + + /** تم مشترک است، پس همان CSS صفحهٔ اصلی باید بارگذاری شود. */ + public function testLandingUsesTheHomepageTheme(): void + { + preg_match_all('~href="(/build/[^"]+\.css)"~', $this->html('/'), $home); + preg_match_all('~href="(/build/[^"]+\.css)"~', $this->html('/' . LandingRegistry::BEAUTY), $landing); + + self::assertNotEmpty($home[1]); + self::assertSame($home[1], $landing[1], 'لندینگ باید همان CSS صفحهٔ اصلی را بارگذاری کند'); + } +} diff --git a/tests/Shared/LandingRegistryTest.php b/tests/Shared/LandingRegistryTest.php new file mode 100644 index 00000000..e9add881 --- /dev/null +++ b/tests/Shared/LandingRegistryTest.php @@ -0,0 +1,93 @@ +registry = new LandingRegistry(); + } + + public function testEverySlugIsRegistered(): void + { + $slugs = array_keys($this->registry->all()); + + self::assertCount(7, $slugs); + self::assertContains(LandingRegistry::BEAUTY, $slugs); + self::assertContains(LandingRegistry::CRM, $slugs); + } + + public function testUnknownSlugReturnsNull(): void + { + self::assertNull($this->registry->find('یک-اسلاگ-ناموجود')); + } + + public function testFindReturnsTheMatchingPage(): void + { + $page = $this->registry->find(LandingRegistry::DENTAL); + + self::assertInstanceOf(LandingPage::class, $page); + self::assertSame(LandingRegistry::DENTAL, $page->slug); + } + + public function testTitlesAndHeadingsAreUnique(): void + { + $titles = array_map(static fn (LandingPage $p): string => $p->metaTitle, $this->registry->all()); + $h1s = array_map(static fn (LandingPage $p): string => $p->h1, $this->registry->all()); + + self::assertCount(7, array_unique($titles), 'عنوان تکراری یعنی محتوای تکراری'); + self::assertCount(7, array_unique($h1s), 'h1 تکراری یعنی محتوای تکراری'); + } + + /** توضیحات کوتاه‌تر از ۱۲۰ فرصت را هدر می‌دهد و بلندتر از ۱۷۰ در نتایج بریده می‌شود. */ + public function testMetaDescriptionLengthIsWithinSearchLimits(): void + { + foreach ($this->registry->all() as $slug => $page) { + $length = mb_strlen($page->metaDescription); + self::assertGreaterThanOrEqual(120, $length, "توضیحات «{$slug}» کوتاه است"); + self::assertLessThanOrEqual(170, $length, "توضیحات «{$slug}» بلند است"); + } + } + + public function testEveryPageHasContentToRender(): void + { + foreach ($this->registry->all() as $slug => $page) { + self::assertNotSame('', trim($page->lead), "lead «{$slug}» خالی است"); + self::assertGreaterThanOrEqual(4, count($page->features), "امکانات «{$slug}» کم است"); + self::assertGreaterThanOrEqual(3, count($page->faq), "پرسش‌های «{$slug}» کم است"); + } + } + + /** لینک داخلی به اسلاگی که وجود ندارد یعنی ۴۰۴ داخل خود سایت. */ + public function testRelatedLinksPointAtRealPages(): void + { + $known = array_keys($this->registry->all()); + + foreach ($this->registry->all() as $slug => $page) { + self::assertNotContains($slug, $page->related, "«{$slug}» به خودش لینک داده"); + foreach ($page->related as $target) { + self::assertContains($target, $known, "«{$slug}» به «{$target}» لینک داده که وجود ندارد"); + } + } + } + + public function testSlugsCarryTheKeywordAndNoSpaces(): void + { + foreach (array_keys($this->registry->all()) as $slug) { + self::assertStringNotContainsString(' ', $slug, 'اسلاگ نباید فاصله داشته باشد'); + self::assertSame(trim($slug, '-'), $slug, 'اسلاگ نباید با خط تیره شروع یا تمام شود'); + } + } +}