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:
hamed
2026-08-10 14:38:27 +03:30
parent 231ce793bc
commit 68b8b05630
16 changed files with 1676 additions and 596 deletions
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace App\Tests\Shared;
use App\Shared\Landing\LandingRegistry;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* صفحات فرود واقعاً رندر می‌شوند و متادیتای هرکدام مالِ خودش است.
*
* روت `/{slug}` هر مسیر تک‌بخشی را می‌گیرد، پس اینجا هم بررسی می‌شود که صفحهٔ اصلی و
* مسیرهای موجود را نخورده باشد.
*/
class LandingPageTest extends WebTestCase
{
private KernelBrowser $client;
private LandingRegistry $registry;
protected function setUp(): void
{
$this->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('<h1 class="reveal">' . $page->h1 . '</h1>', $html);
self::assertStringContainsString('<title>' . $page->metaTitle . '</title>', $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(
'~<link rel="canonical" href="[^"]+/' . preg_quote($slug, '~') . '"/>~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('~<script type="application/ld\+json">(.*?)</script>~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 صفحهٔ اصلی را بارگذاری کند');
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Tests\Shared;
use App\Shared\Landing\LandingPage;
use App\Shared\Landing\LandingRegistry;
use PHPUnit\Framework\TestCase;
/**
* قواعدی که اگر بشکنند، هفت صفحهٔ فرود به‌جای کمک به رتبه، به هم آسیب می‌زنند:
* عنوان یا h1 تکراری یعنی محتوای تکراری، و توضیحات بیرون از بازهٔ استاندارد در
* نتایج جستجو بریده می‌شود.
*/
class LandingRegistryTest extends TestCase
{
private LandingRegistry $registry;
protected function setUp(): void
{
$this->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, 'اسلاگ نباید با خط تیره شروع یا تمام شود');
}
}
}