Commit 0c9d0eec authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(website): make the v3 block builder actually render, and add the capabilities it was missing

The page + block builder shipped in 39f468a9 could not render a single page.
Two independent faults, both fatal:

1. `website/page.blade.php` passes no `$sections`, but `layout.blade.php`
   includes `navbar.blade.php` and `footer.blade.php`, which both dereference
   it. Every builder page died with "Undefined variable $sections" before the
   first block was rendered. `$sections` belongs to the legacy section site;
   both dispatchers now default it and prefer the authored menu tree, so one
   navbar and one footer serve both worlds and existing tenants keep the
   navigation they have.

2. The `ec-*` class layer every block partial styles itself through
   (ec-heading, ec-muted, ec-surface, ec-btn, ec-eyebrow, ec-prose, ec-marquee,
   ec-block) was used in 25+ views and defined in no stylesheet. Even past the
   crash, a rendered page had no colours, no cards, no buttons. The layer is
   now written against the --site-* variables the layout already emits, so a
   theme change repaints the whole site.

Alongside the fix, the capabilities a data-driven marketing site needs:

- Per-language URLs. `/en/...` and `/ar/...` address the same page, SetLocale
  reads the prefix ahead of the session, and the layout emits lang, dir,
  canonical and hreflang alternates from the active locale instead of a
  hardcoded rtl. A shared link now opens in the language it names. `en` and
  `ar` are reserved slugs so a page cannot hide behind a locale prefix.
- One section-header contract: eyebrow, heading, accent rule, subtitle, shared
  by 21 block types through `_header`, with `{braced}` fragments of a heading
  rendered in the accent colour.
- New variants: glass navbar, `overlay_split` profile card, `focus_carousel`
  logo strip, `season_cards` branches. Ambient motion (float/glow/pulse/
  shimmer) exposed in the motion panel; showcase motion on the hero image.
- A footer "powered by" accent band, and footer columns driven by a menu so
  they cannot drift from the navbar.
- Branches gain photo_path and a season window; the block dims a branch that
  is out of season. A branch with no window is open all year, so nothing
  changes for existing data.

Also fixed while in here: `getBranches()` bypasses Eloquent and never checked
`deleted_at`, so a branch deleted in the ERP kept appearing on the public site.

Verified on a local Postgres replica: all 136 block/variant combinations render
with no logged failures, the locale routes answer 200 and reserved paths still
404, migrations apply and roll back cleanly on both an existing tenant and a
from-scratch install + seed, and the suite passes (140 tests, 483 assertions).
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 500806ca
......@@ -3,6 +3,7 @@
.env
.env.backup
.env.production
.env.local
.phpactor.json
.phpunit.result.cache
/.codex
......
......@@ -5,11 +5,13 @@
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Domain\Shared\Traits\ManglesUniqueOnDelete;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
class Branch extends Model
{
......@@ -20,7 +22,9 @@ class Branch extends Model
protected $fillable = [
'academy_id', 'name', 'name_ar', 'code',
'address', 'city', 'governorate', 'phone', 'email',
'latitude', 'longitude', 'is_main', 'is_active',
'latitude', 'longitude', 'photo_path',
'season_starts_on', 'season_ends_on',
'is_main', 'is_active',
'manager_id', 'operating_hours',
];
......@@ -29,9 +33,48 @@ class Branch extends Model
'is_active' => 'boolean',
'latitude' => 'decimal:7',
'longitude' => 'decimal:7',
'season_starts_on' => 'date',
'season_ends_on' => 'date',
'operating_hours' => 'array',
];
/**
* Does the branch have a declared season at all?
*
* A branch with no window is open all year — which is what every branch was
* before the season columns existed, so the absence of a window must never
* read as "closed".
*/
public function hasSeasonWindow(): bool
{
return $this->season_starts_on !== null || $this->season_ends_on !== null;
}
/**
* Is the branch inside its season on the given day?
*
* An open-ended window counts as open on that side: a start with no end is
* "from this date onwards", an end with no start is "until this date".
*/
public function isInSeason(?DateTimeInterface $on = null): bool
{
if (! $this->hasSeasonWindow()) {
return true;
}
$day = ($on ? Carbon::instance($on) : now())->startOfDay();
if ($this->season_starts_on && $day->lt($this->season_starts_on->startOfDay())) {
return false;
}
if ($this->season_ends_on && $day->gt($this->season_ends_on->startOfDay())) {
return false;
}
return true;
}
public function manager(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'manager_id');
......
<?php
namespace App\Domain\Website\Blocks\Concerns;
use App\Domain\Website\Blocks\BlockField;
/**
* The fields behind the shared section header partial.
*
* A block that introduces itself with a title declares these instead of its own
* title/subtitle pair, so every section on a site offers the author the same
* controls and renders through `website.blocks._header`. Without a single
* source, "does this block have an eyebrow?" becomes a per-block accident.
*/
trait HasSectionHeader
{
/** @return BlockField[] */
protected function sectionHeaderFields(): array
{
return [
BlockField::text('eyebrow', 'نص علوي صغير')
->help('كلمة أو كلمتان فوق العنوان، تظهر بلون التمييز'),
BlockField::text('title', 'العنوان')
->help('ضع جزءًا من العنوان بين قوسين معقوفين {هكذا} ليظهر بلون التمييز'),
BlockField::textarea('subtitle', 'العنوان الفرعي'),
BlockField::alignment('header_align', 'محاذاة العنوان'),
BlockField::select('heading_rule', 'خط أسفل العنوان', [
'none' => 'بدون',
'accent_bar' => 'شريط بلون التمييز',
])->default('none'),
];
}
}
......@@ -2,6 +2,7 @@
namespace App\Domain\Website\Blocks;
use App\Domain\Website\Blocks\Concerns\HasSectionHeader;
use App\Domain\Website\Enums\BlockCategory;
/**
......@@ -12,6 +13,8 @@
*/
abstract class DataBlockType extends BlockType
{
use HasSectionHeader;
public function category(): BlockCategory
{
return BlockCategory::Data;
......@@ -31,10 +34,7 @@ protected function filterFields(): array
public function fields(): array
{
return array_merge([
BlockField::text('title', 'العنوان'),
BlockField::textarea('subtitle', 'العنوان الفرعي'),
], $this->filterFields(), [
return array_merge($this->sectionHeaderFields(), $this->filterFields(), [
BlockField::number('limit', 'عدد العناصر')->default(0)
->help('صفر = عرض الكل'),
BlockField::select('columns', 'عدد الأعمدة', [
......
......@@ -4,10 +4,13 @@
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Blocks\Concerns\HasSectionHeader;
use App\Domain\Website\Enums\BlockCategory;
class AccordionBlock extends BlockType
{
use HasSectionHeader;
public function key(): string
{
return 'accordion';
......@@ -41,8 +44,7 @@ public function variants(): array
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::textarea('subtitle', 'العنوان الفرعي'),
...$this->sectionHeaderFields(),
BlockField::toggle('single_open', 'فتح عنصر واحد فقط')->default(true),
BlockField::toggle('use_faq_data', 'استخدام الأسئلة من النظام')
->help('يعرض الأسئلة المسجلة في إدارة الموقع بدلًا من الإدخال اليدوي'),
......
......@@ -57,6 +57,9 @@ public function fields(): array
BlockField::repeater('features', 'مميزات التطبيق', [
BlockField::icon('icon', 'أيقونة'),
BlockField::text('title', 'العنوان'),
BlockField::select('heading_rule', 'خط أسفل العنوان', [
'none' => 'بدون', 'accent_bar' => 'شريط بلون التمييز',
])->default('none'),
BlockField::text('body', 'الوصف'),
]),
];
......
......@@ -31,6 +31,7 @@ public function variants(): array
{
return [
'cards' => 'بطاقات',
'season_cards' => 'بطاقات بالموسم',
'list' => 'قائمة',
'map_split' => 'مع خريطة',
'carousel' => 'شرائح',
......@@ -45,6 +46,10 @@ protected function extraFields(): array
BlockField::toggle('show_phone', 'إظهار الهاتف'),
BlockField::toggle('show_manager', 'إظهار اسم المسؤول'),
BlockField::toggle('show_hours', 'إظهار مواعيد العمل'),
BlockField::toggle('show_season', 'إظهار موسم العمل')->default(true)
->help('يعرض تاريخ فتح الفرع ويُخفت الفروع خارج موسمها'),
BlockField::text('card_hint', 'نص أسفل البطاقة')
->help('مثال: اضغط لمعرفة المزيد'),
];
}
}
......@@ -4,6 +4,7 @@
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Blocks\Concerns\HasSectionHeader;
use App\Domain\Website\Enums\BlockCategory;
/**
......@@ -12,6 +13,8 @@
*/
class CardGridBlock extends BlockType
{
use HasSectionHeader;
public function key(): string
{
return 'card_grid';
......@@ -53,8 +56,7 @@ public function variants(): array
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::textarea('subtitle', 'العنوان الفرعي'),
...$this->sectionHeaderFields(),
BlockField::select('columns', 'عدد الأعمدة', [
'1' => '1', '2' => '2', '3' => '3', '4' => '4',
])->default('3'),
......
......@@ -4,6 +4,7 @@
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Blocks\Concerns\HasSectionHeader;
use App\Domain\Website\Enums\BlockCategory;
/**
......@@ -12,6 +13,8 @@
*/
class ContactFormBlock extends BlockType
{
use HasSectionHeader;
public function key(): string
{
return 'contact_form';
......@@ -45,7 +48,7 @@ public function variants(): array
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
...$this->sectionHeaderFields(),
BlockField::textarea('description', 'الوصف'),
BlockField::text('submit_label', 'نص زر الإرسال')
->default(['ar' => 'إرسال', 'en' => 'Send']),
......
......@@ -44,6 +44,9 @@ public function fields(): array
{
return [
BlockField::text('title', 'العنوان')->required(),
BlockField::select('heading_rule', 'خط أسفل العنوان', [
'none' => 'بدون', 'accent_bar' => 'شريط بلون التمييز',
])->default('none'),
BlockField::textarea('description', 'الوصف'),
BlockField::image('image', 'صورة'),
BlockField::alignment('align', 'المحاذاة'),
......
......@@ -4,6 +4,7 @@
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Blocks\Concerns\HasSectionHeader;
use App\Domain\Website\Enums\BlockCategory;
/**
......@@ -12,6 +13,8 @@
*/
class GalleryBlock extends BlockType
{
use HasSectionHeader;
public function key(): string
{
return 'gallery';
......@@ -48,8 +51,7 @@ public function variants(): array
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::textarea('subtitle', 'العنوان الفرعي'),
...$this->sectionHeaderFields(),
BlockField::radio('source', 'مصدر الصور', [
'manual' => 'رفع يدوي',
'collection' => 'من مكتبة الوسائط',
......
......@@ -57,6 +57,9 @@ public function fields(): array
'three_quarter' => 'ثلاثة أرباع', 'full' => 'ملء الشاشة',
])->default('three_quarter'),
BlockField::alignment('align', 'محاذاة النص'),
BlockField::select('showcase_motion', 'حركة صورة العرض', [
'none' => 'بدون', 'float' => 'طفو', 'glow' => 'توهج', 'float_glow' => 'طفو وتوهج',
])->default('none'),
BlockField::toggle('show_scroll_hint', 'إظهار سهم التمرير')->default(true),
// Multi-colour product showcase (e.g. a kit in several colourways).
......
......@@ -4,11 +4,14 @@
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Blocks\Concerns\HasSectionHeader;
use App\Domain\Website\Enums\BlockCategory;
/** Partner / sponsor logos. */
class LogoStripBlock extends BlockType
{
use HasSectionHeader;
public function key(): string
{
return 'logo_strip';
......@@ -33,6 +36,7 @@ public function variants(): array
{
return [
'grid' => 'شبكة',
'focus_carousel' => 'شرائح مع تركيز على الوسط',
'marquee' => 'شريط متحرك',
'centered' => 'وسط',
'bordered' => 'بفواصل',
......@@ -42,8 +46,7 @@ public function variants(): array
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::textarea('subtitle', 'العنوان الفرعي'),
...$this->sectionHeaderFields(),
BlockField::toggle('use_partner_data', 'استخدام الشركاء من النظام')->default(true),
BlockField::repeater('logos', 'الشعارات', [
BlockField::image('logo', 'الشعار'),
......
......@@ -4,10 +4,13 @@
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Blocks\Concerns\HasSectionHeader;
use App\Domain\Website\Enums\BlockCategory;
class MapBlock extends BlockType
{
use HasSectionHeader;
public function key(): string
{
return 'map';
......@@ -40,7 +43,7 @@ public function variants(): array
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
...$this->sectionHeaderFields(),
BlockField::toggle('use_branch_data', 'استخدام مواقع الفروع من النظام')->default(true),
BlockField::repeater('locations', 'المواقع', [
BlockField::text('name', 'الاسم'),
......
......@@ -4,6 +4,7 @@
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Blocks\Concerns\HasSectionHeader;
use App\Domain\Website\Enums\BlockCategory;
/**
......@@ -12,6 +13,8 @@
*/
class PricingBlock extends BlockType
{
use HasSectionHeader;
public function key(): string
{
return 'pricing';
......@@ -40,8 +43,7 @@ public function variants(): array
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::textarea('subtitle', 'العنوان الفرعي'),
...$this->sectionHeaderFields(),
BlockField::text('currency_label', 'رمز العملة')->default(['ar' => 'ج.م', 'en' => 'EGP']),
BlockField::radio('price_source', 'مصدر الأسعار', [
'manual' => 'إدخال يدوي',
......
......@@ -42,6 +42,7 @@ public function variants(): array
{
return [
'split' => 'مقسّم',
'overlay_split' => 'مقسّم مع اسم على الصورة',
'centered' => 'وسط',
'card' => 'بطاقة',
'quote_focus' => 'التركيز على الاقتباس',
......@@ -51,7 +52,13 @@ public function variants(): array
public function fields(): array
{
return [
BlockField::text('eyebrow', 'نص علوي صغير'),
BlockField::text('section_title', 'عنوان القسم'),
BlockField::select('heading_rule', 'خط أسفل العنوان', [
'none' => 'بدون', 'accent_bar' => 'شريط بلون التمييز',
])->default('none'),
BlockField::text('callout_label', 'عنوان الصندوق المميز'),
BlockField::text('callout_value', 'محتوى الصندوق المميز'),
BlockField::image('photo', 'الصورة')->required(),
BlockField::text('name', 'الاسم')->required(),
BlockField::text('role', 'المنصب'),
......
......@@ -4,11 +4,14 @@
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Blocks\Concerns\HasSectionHeader;
use App\Domain\Website\Enums\BlockCategory;
/** Generic full-width container. The backbone of every custom layout. */
class SectionBlock extends BlockType
{
use HasSectionHeader;
public function key(): string
{
return 'section';
......@@ -51,10 +54,7 @@ public function variants(): array
public function fields(): array
{
return [
BlockField::text('eyebrow', 'نص علوي صغير'),
BlockField::text('title', 'العنوان'),
BlockField::textarea('subtitle', 'العنوان الفرعي'),
BlockField::alignment('header_align', 'محاذاة العنوان'),
...$this->sectionHeaderFields(),
BlockField::select('max_width', 'أقصى عرض', [
'sm' => 'صغير', 'md' => 'متوسط', 'lg' => 'كبير',
'xl' => 'كبير جدًا', 'full' => 'كامل',
......
......@@ -4,10 +4,13 @@
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Blocks\Concerns\HasSectionHeader;
use App\Domain\Website\Enums\BlockCategory;
class StatsBlock extends BlockType
{
use HasSectionHeader;
public function key(): string
{
return 'stats';
......@@ -41,7 +44,7 @@ public function variants(): array
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
...$this->sectionHeaderFields(),
BlockField::toggle('animate_count', 'تحريك العد التصاعدي')->default(true),
BlockField::repeater('items', 'الأرقام', [
BlockField::icon('icon', 'أيقونة'),
......
......@@ -45,6 +45,13 @@ public function fields(): array
BlockField::text('eyebrow', 'نص علوي صغير'),
BlockField::text('title', 'العنوان')->required(),
BlockField::richText('body', 'النص'),
BlockField::select('heading_rule', 'خط أسفل العنوان', [
'none' => 'بدون', 'accent_bar' => 'شريط بلون التمييز',
])->default('none'),
BlockField::toggle('body_accent_edge', 'خط جانبي بلون التمييز بجوار النص'),
BlockField::select('image_frame', 'إطار الصورة', [
'none' => 'بدون', 'offset_outline' => 'إطار مزاح', 'border' => 'حد رفيع', 'tilt' => 'مائل',
])->default('none'),
BlockField::image('image', 'الصورة')->required(),
BlockField::text('image_caption', 'تعليق على الصورة'),
BlockField::select('image_ratio', 'نسبة الصورة', [
......
......@@ -44,6 +44,9 @@ public function fields(): array
return [
BlockField::text('eyebrow', 'نص علوي صغير'),
BlockField::text('title', 'العنوان'),
BlockField::select('heading_rule', 'خط أسفل العنوان', [
'none' => 'بدون', 'accent_bar' => 'شريط بلون التمييز',
])->default('none'),
BlockField::video('url', 'رابط الفيديو')->required()
->help('يدعم يوتيوب و Vimeo أو رابط ملف مباشر'),
BlockField::image('poster', 'صورة الغلاف'),
......
......@@ -75,8 +75,14 @@ public function localizedTitle(?string $locale = null): string
: ($this->title_en ?: $this->title ?: $this->slug);
}
public function url(): string
/**
* The page's public URL in a given locale (the active one by default).
*
* Always locale-prefixed: an internal link must not drop the reader back
* into whatever language their session happens to hold.
*/
public function url(?string $locale = null): string
{
return $this->is_homepage ? url('/') : url('/'.ltrim($this->slug, '/'));
return website_url($this->is_homepage ? '' : $this->slug, $locale);
}
}
......@@ -38,6 +38,8 @@ class WebsiteSetting extends Model
'navbar_cta_link',
'navbar_show_social',
'navbar_logo_position',
'navbar_secondary_logo_path',
'navbar_show_language',
'site_title',
'site_title_en',
'site_description',
......@@ -50,6 +52,7 @@ class WebsiteSetting extends Model
'footer_columns',
'footer_blocks',
'footer_bottom_text',
'footer_powered_by',
'footer_style',
'floating_elements',
'announcement_bar',
......@@ -61,12 +64,14 @@ class WebsiteSetting extends Model
protected $casts = [
'social_links' => 'array',
'footer_blocks' => 'array',
'footer_powered_by' => 'array',
'floating_elements' => 'array',
'announcement_bar' => 'array',
'popup_config' => 'array',
'is_published' => 'boolean',
'animations_enabled' => 'boolean',
'navbar_show_social' => 'boolean',
'navbar_show_language' => 'boolean',
'footer_columns' => 'integer',
'published_at' => 'datetime',
];
......
......@@ -68,25 +68,34 @@ public function getBranches(Academy $academy): Collection
$result = Cache::remember(
"website.{$academy->id}.branches",
3600,
fn () => DB::table('branches')
->where('academy_id', $academy->id)
->where('is_active', true)
->orderByDesc('is_main')
->get()
fn () => $this->branchQuery($academy)->get()
);
if (!$result instanceof Collection) {
Cache::forget("website.{$academy->id}.branches");
return DB::table('branches')
->where('academy_id', $academy->id)
->where('is_active', true)
->orderByDesc('is_main')
->get();
return $this->branchQuery($academy)->get();
}
return $result;
}
/**
* The branches a public site may show.
*
* `branches` is soft-deleted, and this query bypasses Eloquent — so without
* the deleted_at check a branch removed in the ERP keeps appearing on the
* website until someone notices.
*/
private function branchQuery(Academy $academy)
{
return DB::table('branches')
->where('academy_id', $academy->id)
->where('is_active', true)
->whereNull('deleted_at')
->orderByDesc('is_main')
->orderBy('id');
}
public function getStats(Academy $academy): array
{
return Cache::remember(
......
......@@ -16,6 +16,9 @@ class WebsitePageService
'admin', 'login', 'logout', 'register', 'password', 'api', 'public', 'app',
'website', 'parent', 'trainer', 'receptionist', 'dashboard', 'storage',
'livewire', 'up', 'health',
// Locale prefixes address the whole site in a language; a page slug that
// collided with one would be unreachable behind its own prefix.
'en', 'ar',
];
public function create(array $data, User $actor): WebsitePage
......
<?php
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
if (! function_exists('clean_html')) {
......@@ -225,3 +226,98 @@ function safe_url(?string $url): ?string
return $raw;
}
}
if (! function_exists('website_locales')) {
/** The locales the public site can be served in, default first. */
function website_locales(): array
{
return ['ar', 'en'];
}
}
if (! function_exists('website_locale_from_path')) {
/**
* Reads a locale out of the first path segment, or null when there is none.
*
* The public site answers both `/about-us` and `/en/about-us`. The prefix is
* what makes a page addressable per language — without it a shared link
* lands the reader in whatever locale their own session happens to hold.
*/
function website_locale_from_path(string $path): ?string
{
$first = strtok(trim($path, '/'), '/');
return in_array($first, website_locales(), true) ? $first : null;
}
}
if (! function_exists('website_strip_locale')) {
/** Removes a leading locale segment from a path, leaving the page slug. */
function website_strip_locale(string $path): string
{
$path = trim($path, '/');
$locale = website_locale_from_path($path);
if ($locale === null) {
return $path;
}
return ltrim(substr($path, strlen($locale)), '/');
}
}
if (! function_exists('website_url')) {
/**
* A public-site URL carrying its locale in the path.
*
* Every internal link on the public site goes through this, so following a
* link never silently changes the language the reader chose.
*/
function website_url(string $slug = '', ?string $locale = null): string
{
$locale = in_array($locale, website_locales(), true) ? $locale : app()->getLocale();
$locale = in_array($locale, website_locales(), true) ? $locale : website_locales()[0];
$slug = trim(website_strip_locale($slug), '/');
return url($slug === '' ? '/'.$locale : '/'.$locale.'/'.$slug);
}
}
if (! function_exists('website_current_url_in')) {
/** The URL of the page being viewed, in another locale. */
function website_current_url_in(string $locale): string
{
$query = request()->getQueryString();
return website_url(website_strip_locale(request()->path()), $locale)
.($query ? '?'.$query : '');
}
}
if (! function_exists('website_highlight')) {
/**
* Renders `{braced}` fragments of a heading in the site's accent colour.
*
* "Welcome to {OC-Sport}" is one editable string with one emphasis rule, not
* two fields the editor has to keep in sync. Everything outside the braces
* is escaped, so a heading remains plain text as far as the author is
* concerned — the braces are the only markup they can express.
*/
function website_highlight(?string $text): HtmlString
{
if (blank($text)) {
return new HtmlString('');
}
$html = preg_replace_callback(
'/\{([^{}]*)\}/u',
// $text is escaped before the match runs, so the captured fragment
// is already safe — escaping it again would double-encode entities.
fn (array $m) => '<span class="ec-accent-text">'.$m[1].'</span>',
e($text, false),
);
return new HtmlString($html ?? e($text));
}
}
......@@ -27,9 +27,19 @@ public function __construct(
*/
public function fallback(Request $request)
{
$slug = trim($request->path(), '/');
// `/en/about-us` and `/about-us` address the same page in different
// languages. SetLocale has already read the prefix; here it is removed
// so the slug lookup sees the page, not the language.
$slug = website_strip_locale($request->path());
// A bare locale (`/en`) is that language's homepage, not a missing page.
if ($slug === '' || $slug === '/') {
if (website_locale_from_path($request->path()) !== null) {
// Same handoff as "/": an unmigrated tenant keeps its legacy site
// under a locale prefix rather than 404-ing on it.
return $this->homeOrLegacy($request, app(PublicWebsiteController::class));
}
throw new NotFoundHttpException;
}
......
......@@ -8,11 +8,31 @@
class SetLocale
{
/**
* Resolves the request locale, URL first.
*
* The public website is addressable per language (`/en/about-us`,
* `/ar/about-us`), so a locale in the path is a statement about *this*
* page and must beat whatever the session remembers — otherwise a link
* shared into a group chat renders in the reader's last-used language
* rather than the one the link names.
*
* A URL locale is also written back to the session, so the reader stays in
* that language when they follow a link that carries no prefix.
*/
public function handle(Request $request, Closure $next): Response
{
$locale = session('locale', config('app.locale', 'ar'));
$supported = website_locales();
if (in_array($locale, ['ar', 'en'])) {
$locale = website_locale_from_path($request->path());
if ($locale !== null) {
session(['locale' => $locale]);
} else {
$locale = session('locale', config('app.locale', 'ar'));
}
if (in_array($locale, $supported, true)) {
app()->setLocale($locale);
}
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Give a branch the three things a public website needs to present it and the
* `branches` table never stored: a photo, and the window of the year it is
* actually open for.
*
* The website builder's branch block already read `photo_path` — a column that
* did not exist — so every site that enabled branch images silently rendered
* none. This is the column it was always reaching for.
*
* The season window is nullable on purpose. A null window means "open all
* year", which is what every existing branch is, so no backfill is required
* and no live site changes appearance on deploy.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('branches')) {
return;
}
Schema::table('branches', function (Blueprint $table) {
if (! Schema::hasColumn('branches', 'photo_path')) {
$table->string('photo_path')->nullable()->after('longitude');
}
if (! Schema::hasColumn('branches', 'season_starts_on')) {
$table->date('season_starts_on')->nullable()->after('photo_path');
}
if (! Schema::hasColumn('branches', 'season_ends_on')) {
$table->date('season_ends_on')->nullable()->after('season_starts_on');
}
});
}
public function down(): void
{
if (! Schema::hasTable('branches')) {
return;
}
Schema::table('branches', function (Blueprint $table) {
foreach (['season_ends_on', 'season_starts_on', 'photo_path'] as $column) {
if (Schema::hasColumn('branches', $column)) {
$table->dropColumn($column);
}
}
});
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Presentation settings the navbar and footer had no way to express.
*
* - `navbar_secondary_logo_path`: a second lock-up beside the academy logo, for
* organisations that co-brand (a parent company, a sponsor lock-up).
* - `navbar_show_language`: whether the public navbar offers a language switch.
* Defaults to false so no existing site grows a control it never had.
* - `footer_powered_by`: an optional accent bar under the footer — a label and
* a list of logos. Null means no bar, which is what every current site shows.
*
* All three are additive and nullable; a site that never sets them renders
* exactly as it does today.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('website_settings')) {
return;
}
Schema::table('website_settings', function (Blueprint $table) {
if (! Schema::hasColumn('website_settings', 'navbar_secondary_logo_path')) {
$table->string('navbar_secondary_logo_path')->nullable()->after('navbar_logo_position');
}
if (! Schema::hasColumn('website_settings', 'navbar_show_language')) {
$table->boolean('navbar_show_language')->default(false)->after('navbar_secondary_logo_path');
}
if (! Schema::hasColumn('website_settings', 'footer_powered_by')) {
$table->jsonb('footer_powered_by')->nullable()->after('footer_bottom_text');
}
});
}
public function down(): void
{
if (! Schema::hasTable('website_settings')) {
return;
}
Schema::table('website_settings', function (Blueprint $table) {
foreach (['footer_powered_by', 'navbar_show_language', 'navbar_secondary_logo_path'] as $column) {
if (Schema::hasColumn('website_settings', $column)) {
$table->dropColumn($column);
}
}
});
}
};
......@@ -782,3 +782,287 @@
}
}
}
/* ══════════════════════════════════════════════════════════════════════════
Block builder design layer (`ec-*`)
Every block partial written for the v3 builder styles itself through these
classes rather than hardcoded colours, so one theme change repaints the
whole site. They bind to the --site-* variables the layout emits from
website_settings; nothing here assumes a particular palette.
══════════════════════════════════════════════════════════════════════════ */
@layer components {
/* ─── Surfaces and type ─── */
.ec-block {
color: var(--site-text);
font-family: var(--site-body-font);
}
.ec-heading {
color: var(--site-text);
font-family: var(--site-heading-font);
font-weight: var(--site-heading-weight, 800);
letter-spacing: var(--site-letter-spacing, 0);
}
.ec-muted { color: var(--site-muted); }
.ec-accent { color: var(--site-accent); }
.ec-accent-text { color: var(--site-accent); }
/* An eyebrow labels the section above its heading: small, spaced, accent. */
.ec-eyebrow {
color: var(--site-accent);
text-transform: uppercase;
letter-spacing: 0.22em;
font-weight: 700;
font-size: 0.8125rem;
}
.ec-surface {
background: var(--site-surface);
border: 1px solid var(--site-border);
border-radius: var(--site-radius-lg, 20px);
color: var(--site-text);
}
/* ─── The rule under a section heading ─── */
.ec-heading-rule::after {
content: "";
display: block;
width: 4.5rem;
height: 4px;
margin-block-start: 0.75rem;
border-radius: 999px;
background: var(--site-accent);
}
.ec-heading-rule--center::after { margin-inline: auto; }
.ec-heading-rule--start::after { margin-inline-end: auto; }
/* A quoted or emphasised passage, marked by a rule on its leading edge. */
.ec-accent-edge {
border-inline-start: 3px solid var(--site-accent);
padding-inline-start: 1.25rem;
}
/* ─── Buttons ─── */
.ec-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: var(--site-radius, 12px);
font-weight: 600;
line-height: 1.2;
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, color 0.2s ease;
cursor: pointer;
}
.ec-btn:hover { transform: translateY(-1px); }
.ec-btn--primary {
background: var(--site-accent);
color: var(--site-primary);
box-shadow: 0 6px 20px -8px color-mix(in srgb, var(--site-accent) 70%, transparent);
}
.ec-btn--primary:hover {
box-shadow: 0 10px 26px -8px color-mix(in srgb, var(--site-accent) 85%, transparent);
}
.ec-btn--secondary {
background: var(--site-secondary);
color: #fff;
}
.ec-btn--outline {
border: 2px solid var(--site-accent);
color: var(--site-accent);
background: transparent;
}
.ec-btn--outline:hover {
background: var(--site-accent);
color: var(--site-primary);
}
.ec-btn--ghost {
background: color-mix(in srgb, currentColor 10%, transparent);
color: inherit;
}
/* ─── Rich text ─── */
.ec-prose { line-height: var(--site-line-height, 1.7); }
.ec-prose :is(h1, h2, h3, h4) {
color: var(--site-text);
font-family: var(--site-heading-font);
font-weight: 700;
margin-block: 1.5em 0.5em;
}
.ec-prose h2 { font-size: var(--site-h2); }
.ec-prose h3 { font-size: var(--site-h3); }
.ec-prose p { margin-block-end: 1em; }
.ec-prose :is(ul, ol) { margin-block-end: 1em; padding-inline-start: 1.5em; }
.ec-prose ul { list-style: disc; }
.ec-prose ol { list-style: decimal; }
.ec-prose li { margin-block-end: 0.4em; }
.ec-prose a { color: var(--site-accent); text-decoration: underline; }
.ec-prose strong { font-weight: 700; color: var(--site-text); }
.ec-prose blockquote {
border-inline-start: 3px solid var(--site-accent);
padding-inline-start: 1rem;
font-style: italic;
color: var(--site-muted);
}
/* ─── Navigation ─── */
.site-nav--glass {
background: color-mix(in srgb, var(--site-primary) 72%, transparent);
backdrop-filter: blur(25px);
-webkit-backdrop-filter: blur(25px);
border-block-end: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.site-nav--glass.site-nav--scrolled {
background: color-mix(in srgb, var(--site-primary) 92%, transparent);
}
/* A light sweeps across a nav item on hover. */
.ec-nav-item {
position: relative;
overflow: hidden;
}
.ec-nav-item::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: transform 0.5s ease;
pointer-events: none;
}
.ec-nav-item:hover::after { transform: translateX(100%); }
.ec-nav-dropdown {
background: color-mix(in srgb, var(--site-primary) 96%, transparent);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
color: #fff;
}
/* ─── Marquee ─── */
.ec-marquee { position: relative; }
.ec-marquee-track {
width: max-content;
animation: ec-marquee-scroll var(--marquee-duration, 30s) linear infinite;
}
.ec-marquee:hover .ec-marquee-track { animation-play-state: paused; }
@keyframes ec-marquee-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
[dir="rtl"] .ec-marquee-track {
animation-name: ec-marquee-scroll-rtl;
}
@keyframes ec-marquee-scroll-rtl {
from { transform: translateX(0); }
to { transform: translateX(50%); }
}
/* ─── Focus carousel: the centred item is the one in focus ─── */
.ec-focus-item {
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.5s ease, filter 0.5s ease;
opacity: 0.45;
filter: grayscale(1);
}
.ec-focus-item[data-active="true"] {
opacity: 1;
filter: none;
transform: scale(1.35);
}
.ec-focus-tile {
border-radius: var(--site-radius, 12px);
transition: background 0.5s ease, box-shadow 0.5s ease, border-color 0.5s ease;
border: 2px solid transparent;
}
.ec-focus-item[data-active="true"] .ec-focus-tile {
background: #f4f4f5;
border-color: var(--site-accent);
box-shadow: 0 0 28px -4px color-mix(in srgb, var(--site-accent) 75%, transparent);
}
/* ─── A card whose subject is out of season ─── */
.ec-card-dormant {
opacity: 0.45;
filter: grayscale(0.7);
}
.ec-card-dormant:hover { transform: none; }
/* ─── Footer accent band ─── */
.ec-powered-bar { width: 100%; }
/* ─── Framed media ─── */
.ec-frame-offset {
position: relative;
}
.ec-frame-offset > img {
position: relative;
z-index: 1;
border-radius: var(--site-radius-lg, 20px);
}
/* The outline sits behind and offset, so the photo reads as pinned to it. */
.ec-frame-offset::before {
content: "";
position: absolute;
inset-block: 1.25rem -1.25rem;
inset-inline: 1.25rem -1.25rem;
border: 1px solid color-mix(in srgb, var(--site-accent) 55%, transparent);
border-radius: var(--site-radius-lg, 20px);
pointer-events: none;
}
.ec-frame-border > img {
border: 1px solid color-mix(in srgb, var(--site-accent) 55%, transparent);
border-radius: var(--site-radius-lg, 20px);
}
.ec-frame-tilt > img {
border-radius: var(--site-radius-lg, 20px);
transform: rotate(-1.5deg);
transition: transform 0.5s ease;
}
.ec-frame-tilt:hover > img { transform: rotate(0deg); }
}
/* Everything above is decoration; none of it should move for a reader who
asked their system to stop animating things. */
@media (prefers-reduced-motion: reduce) {
.ec-marquee-track,
.ec-nav-item::after,
.ec-focus-item,
.ec-frame-tilt > img {
animation: none !important;
transition: none !important;
}
}
[data-animations="false"] .ec-marquee-track {
animation: none !important;
}
......@@ -32,6 +32,17 @@
'phone' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h2.2a1 1 0 01.97.757l.75 3a1 1 0 01-.29.98l-1.4 1.32a13 13 0 005.71 5.71l1.32-1.4a1 1 0 01.98-.29l3 .75A1 1 0 0120 16.8V19a1 1 0 01-1 1A15 15 0 014 5z"/>',
'envelope' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16a1 1 0 011 1v10a1 1 0 01-1 1H4a1 1 0 01-1-1V7a1 1 0 011-1z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.5 7l8.5 6 8.5-6"/>',
'check' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>',
'building-office' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 21V5a2 2 0 012-2h6a2 2 0 012 2v16M15 21V9h3a2 2 0 012 2v10M3 21h18"/><path stroke-linecap="round" stroke-width="2" d="M8 7h2M8 11h2M8 15h2"/>',
'trophy' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 4h8v5a4 4 0 11-8 0V4z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H5.5A1.5 1.5 0 004 6.5C4 8.5 5.5 10 8 10M16 5h2.5A1.5 1.5 0 0120 6.5c0 2-1.5 3.5-4 3.5M10 13v3M14 13v3M8 20h8"/>',
'users' => '<circle cx="9" cy="8" r="3.2" stroke-width="2"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.5 19a5.5 5.5 0 0111 0M16 6.2a3 3 0 010 5.6M17.5 19a5 5 0 00-2-4"/>',
'bolt' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 3L5 13h6l-1 8 8-10h-6l1-8z"/>',
'lock-closed' => '<rect x="5" y="10" width="14" height="10" rx="2" stroke-width="2"/><path stroke-linecap="round" stroke-width="2" d="M8.5 10V7.5a3.5 3.5 0 117 0V10"/>',
'eye' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="3" stroke-width="2"/>',
'flag' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 21V4m0 0h11l-2 3.5L16 11H5"/>',
'award' => '<circle cx="12" cy="9" r="5" stroke-width="2"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 13.5L8 21l4-2 4 2-1-7.5"/>',
'academic-cap' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4L2.5 8.5 12 13l9.5-4.5L12 4z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6.5 10.7V15c0 1.4 2.5 2.8 5.5 2.8s5.5-1.4 5.5-2.8v-4.3M20.5 9v5"/>',
'sparkles' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3zM18 15l.8 2.2L21 18l-2.2.8L18 21l-.8-2.2L15 18l2.2-.8L18 15z"/>',
'chat-bubble' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12a7 7 0 01-7 7H8l-4 3 1-4.2A7 7 0 1120 12z"/>',
'x-mark' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>',
'star' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.3-4.1 5.9-.9z"/>',
'globe' => '<circle cx="12" cy="12" r="9" stroke-width="2"/><path stroke-width="2" d="M3 12h18M12 3c2.5 2.6 2.5 15.4 0 18M12 3c-2.5 2.6-2.5 15.4 0 18"/>',
......
{{-- Shared heading used by the data-bound blocks. --}}
@if ($block->get('title') || $block->get('subtitle'))
<header class="text-center max-w-3xl mx-auto mb-10">
@if ($block->get('title'))
<h2 class="ec-heading text-3xl sm:text-4xl font-bold">{{ $block->get('title') }}</h2>
{{--
The shared section header: eyebrow, heading, accent rule, subtitle.
Every block that introduces itself with a title renders it through here, so
the heading treatment is one decision for the whole site rather than a
different set of utility classes in each block partial.
The heading passes through website_highlight(), so an author can emphasise
part of a title by bracing it: "Welcome to {OC-Sport}".
@param string $headerAlign overrides the block's own header_align
@param string $headingSize type scale for the heading
@param string $headerClass extra classes on the <header>
--}}
@php
$align = $headerAlign ?? $block->get('header_align') ?? 'center';
$alignClass = [
'start' => 'text-start',
'center' => 'text-center mx-auto',
'end' => 'text-end ms-auto',
][$align] ?? 'text-center mx-auto';
$ruleClass = $block->get('heading_rule') === 'accent_bar'
? 'ec-heading-rule '.($align === 'center' ? 'ec-heading-rule--center' : 'ec-heading-rule--start')
: '';
$eyebrow = $block->get('eyebrow');
$headerTitle = $block->get('title');
$subtitle = $block->get('subtitle');
$headingSize = $headingSize ?? 'text-3xl sm:text-4xl';
@endphp
@if (filled($eyebrow) || filled($headerTitle) || filled($subtitle))
<header class="max-w-3xl mb-10 {{ $alignClass }} {{ $headerClass ?? '' }}">
@if (filled($eyebrow))
<p class="ec-eyebrow mb-3">{{ $eyebrow }}</p>
@endif
@if ($block->get('subtitle'))
<p class="ec-muted mt-3 text-lg">{{ $block->get('subtitle') }}</p>
@if (filled($headerTitle))
<h2 class="ec-heading {{ $headingSize }} font-black leading-tight {{ $ruleClass }}">
{!! website_highlight($headerTitle) !!}
</h2>
@endif
@if (filled($subtitle))
<p class="ec-muted mt-4 text-lg leading-relaxed">{{ $subtitle }}</p>
@endif
</header>
@endif
......@@ -27,6 +27,14 @@
$animation = $get('animation', 'none');
$anchor = $get('anchor') ?: 'block-' . $block->uuid;
// A continuous effect, distinct from the one-shot entrance animation above.
$ambient = [
'float' => 'ec-float',
'glow' => 'ec-glow',
'pulse' => 'ec-pulse-soft',
'shimmer' => 'ec-shimmer',
][$get('ambient', 'none')] ?? '';
$inline = [];
if ($bgType === 'solid' && $get('bg_color')) {
$inline[] = 'background-color:' . $get('bg_color');
......@@ -52,7 +60,7 @@
data-animation-delay="{{ (int) $get('animation_delay', 0) }}"
data-animation-stagger="{{ (int) $get('animation_stagger', 0) }}"
@endif
class="ec-block relative {{ $padding }} {{ $visibility }} {{ $get('custom_classes', '') }}"
class="ec-block relative {{ $padding }} {{ $visibility }} {{ $ambient }} {{ $get('custom_classes', '') }}"
@if ($inline) style="{{ implode(';', $inline) }}" @endif>
@if ($bgType === 'image' && $get('bg_image'))
......
@php
$cols = ['1' => '', '2' => 'sm:grid-cols-2', '3' => 'sm:grid-cols-2 lg:grid-cols-3', '4' => 'sm:grid-cols-2 lg:grid-cols-4'][$block->get('columns', '3')] ?? 'sm:grid-cols-2 lg:grid-cols-3';
$cols = [
'1' => '',
'2' => 'sm:grid-cols-2',
'3' => 'sm:grid-cols-2 lg:grid-cols-3',
'4' => 'sm:grid-cols-2 lg:grid-cols-4',
][$block->get('columns', '3')] ?? 'sm:grid-cols-2 lg:grid-cols-3';
$ar = app()->getLocale() === 'ar';
$season = $variant === 'season_cards';
$showSeason = $season && $block->get('show_season', true);
$today = now()->startOfDay();
// A branch with no declared window is open all year: the absence of dates
// must never read as "closed".
$inSeason = function ($b) use ($today) {
$from = filled($b->season_starts_on ?? null) ? \Illuminate\Support\Carbon::parse($b->season_starts_on)->startOfDay() : null;
$to = filled($b->season_ends_on ?? null) ? \Illuminate\Support\Carbon::parse($b->season_ends_on)->startOfDay() : null;
if (! $from && ! $to) {
return true;
}
return ! (($from && $today->lt($from)) || ($to && $today->gt($to)));
};
@endphp
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"
@if ($block->get('show_search')) x-data="{ q: '' }" @endif>
@include('website.blocks._header')
......@@ -10,7 +33,7 @@
<div class="max-w-md mx-auto mb-8">
<input type="search" x-model="q"
placeholder="{{ __('ابحث بالاسم أو المنطقة') }}"
class="w-full rounded-full border px-5 py-3 focus:outline-none focus:ring-2"
class="ec-surface w-full rounded-full px-5 py-3 focus:outline-none focus:ring-2"
aria-label="{{ __('بحث في الفروع') }}">
</div>
@endif
......@@ -24,29 +47,68 @@ class="w-full rounded-full border px-5 py-3 focus:outline-none focus:ring-2"
$name = $ar ? ($branch->name_ar ?? $branch->name) : ($branch->name ?? $branch->name_ar);
$loc = $branch->address ?? $branch->location ?? null;
$photo = $branch->photo_path ?? null;
$open = $inSeason($branch);
$from = filled($branch->season_starts_on ?? null) ? \Illuminate\Support\Carbon::parse($branch->season_starts_on) : null;
$to = filled($branch->season_ends_on ?? null) ? \Illuminate\Support\Carbon::parse($branch->season_ends_on) : null;
@endphp
<article class="ec-stagger-item ec-surface rounded-2xl overflow-hidden shadow-sm flex flex-col transition hover:-translate-y-1"
@if ($block->get('show_search')) x-show="!q || @js(mb_strtolower($name . ' ' . $loc)).includes(q.toLowerCase())" @endif>
<article class="ec-stagger-item ec-surface relative flex flex-col overflow-hidden rounded-2xl shadow-sm transition hover:-translate-y-1
{{ $season ? 'border-t-2 border-t-[var(--site-accent)]' : '' }}
{{ $showSeason && ! $open ? 'ec-card-dormant' : '' }}"
@if ($block->get('show_search')) x-show="!q || @js(mb_strtolower($name.' '.$loc)).includes(q.toLowerCase())" @endif>
@if ($block->get('show_image', true) && $photo)
<img src="{{ $photo }}" alt="{{ $name }}" loading="lazy" class="w-full aspect-[3/2] object-cover">
@endif
<div class="flex flex-col gap-2 p-6 flex-1">
<h3 class="ec-heading text-xl font-semibold">{{ $name }}</h3>
@if ($block->get('show_location', true) && $loc)
<p class="ec-muted flex items-start gap-2 text-sm">
<x-website.icon name="map-pin" class="w-4 h-4 mt-0.5 shrink-0" />
<span>{{ $loc }}</span>
</p>
@endif
<div class="flex flex-1 flex-col gap-2 p-6">
<div class="flex items-start gap-3">
@if ($season)
<span class="ec-accent inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl"
style="background: color-mix(in srgb, var(--site-accent) 15%, transparent)" aria-hidden="true">
<x-website.icon name="building-office" class="w-5 h-5" />
</span>
@endif
<div class="min-w-0">
<h3 class="ec-heading text-lg font-extrabold leading-tight">{{ $name }}</h3>
@if ($block->get('show_location', true) && $loc)
@if ($season)
<span class="ec-accent mt-1.5 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[0.7rem] font-bold uppercase tracking-wide"
style="background: color-mix(in srgb, var(--site-accent) 18%, transparent)">
<x-website.icon name="map-pin" class="w-3.5 h-3.5" />
{{ $loc }}
</span>
@else
<p class="ec-muted mt-1 flex items-start gap-2 text-sm">
<x-website.icon name="map-pin" class="w-4 h-4 mt-0.5 shrink-0" />
<span>{{ $loc }}</span>
</p>
@endif
@endif
</div>
</div>
@if ($block->get('show_phone') && ($branch->phone ?? null))
<p class="ec-muted flex items-center gap-2 text-sm" dir="ltr">
<x-website.icon name="phone" class="w-4 h-4 shrink-0" />
<a href="tel:{{ $branch->phone }}">{{ $branch->phone }}</a>
</p>
@endif
@if ($block->get('show_manager') && ($branch->manager_name ?? null))
<p class="ec-muted text-sm">{{ __('المسؤول') }}: {{ $branch->manager_name }}</p>
@endif
@if ($showSeason && ! $open && ($from || $to))
<p class="ec-muted mt-2 text-sm font-medium" dir="ltr">
{{ __('يفتح') }}
{{ $from?->format('d/m/Y') }}@if ($from && $to) – @endif{{ $to?->format('d/m/Y') }}
</p>
@endif
@if ($season && $block->get('card_hint'))
<p class="ec-muted mt-auto pt-4 text-center text-sm">{{ $block->get('card_hint') }}</p>
@endif
</div>
</article>
@endforeach
......
......@@ -9,6 +9,13 @@
$products = collect($block->get('products') ?: []);
$slides = collect($block->get('slides') ?: []);
$split = in_array($variant, ['split_start', 'split_end'], true);
// Ambient motion for the showcase image only — the section around it stays put.
$showcaseMotion = trim([
'float' => 'ec-float',
'glow' => 'ec-glow',
'float_glow' => 'ec-float ec-glow',
][$block->get('showcase_motion', 'none')] ?? '');
@endphp
<div class="relative {{ $height }} flex items-center">
......@@ -35,14 +42,16 @@ class="absolute inset-0 h-full w-full object-cover transition-opacity duration-1
<div class="@if ($split) grid md:grid-cols-2 gap-12 items-center @endif">
@if ($split && $variant === 'split_start' && $block->get('image'))
<div class="order-1"><img src="{{ $block->get('image') }}" alt="{{ $block->get('title') }}" class="w-full rounded-2xl object-cover"></div>
<div class="order-1 {{ $showcaseMotion }}"><img src="{{ $block->get('image') }}" alt="{{ $block->get('title') }}" class="w-full rounded-2xl object-cover"></div>
@endif
<div class="flex flex-col gap-6 {{ $split ? 'text-start items-start' : $alignClass }} @if (! $split) max-w-3xl mx-auto @endif">
@if ($block->get('eyebrow'))
<p class="ec-eyebrow text-base font-medium tracking-wide">{{ $block->get('eyebrow') }}</p>
@endif
<h1 class="ec-heading text-4xl sm:text-5xl lg:text-6xl font-extrabold leading-tight">{{ $block->get('title') }}</h1>
<h1 class="ec-heading text-4xl sm:text-5xl lg:text-6xl font-extrabold leading-tight">
{!! website_highlight($block->get('title')) !!}
</h1>
@if ($block->get('subtitle'))
<p class="ec-muted text-lg sm:text-xl leading-relaxed max-w-2xl">{{ $block->get('subtitle') }}</p>
@endif
......@@ -66,7 +75,7 @@ class="ec-btn ec-btn--{{ data_get($btn, 'style', 'primary') }} inline-flex items
@if ($variant === 'product_showcase' && $products->isNotEmpty())
<div x-data="{ active: 0, back: false }" class="flex flex-col items-center gap-6">
<div class="relative w-full max-w-sm aspect-square">
<div class="relative w-full max-w-sm aspect-square {{ $showcaseMotion }}">
@foreach ($products as $idx => $product)
@foreach (['front', 'back'] as $face)
@if (data_get($product, $face))
......@@ -93,7 +102,7 @@ class="w-8 h-8 rounded-full border-2 transition"
</button>
</div>
@elseif ($split && $variant === 'split_end' && $block->get('image'))
<div><img src="{{ $block->get('image') }}" alt="{{ $block->get('title') }}" class="w-full rounded-2xl object-cover"></div>
<div class="{{ $showcaseMotion }}"><img src="{{ $block->get('image') }}" alt="{{ $block->get('title') }}" class="w-full rounded-2xl object-cover"></div>
@endif
</div>
</div>
......
@php
$gray = $block->get('grayscale', true) ? 'grayscale opacity-70 hover:grayscale-0 hover:opacity-100' : '';
$speed = ['slow' => '45s', 'normal' => '30s', 'fast' => '18s'][$block->get('speed', 'normal')] ?? '30s';
$dwell = ['slow' => 4200, 'normal' => 3000, 'fast' => 1800][$block->get('speed', 'normal')] ?? 3000;
$logo = fn ($p) => is_array($p) ? (data_get($p, 'logo') ?: data_get($p, 'logo_path')) : $p;
$name = fn ($p) => is_array($p) ? (data_get($p, 'name') ?: data_get($p, 'name_ar')) : '';
@endphp
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@if ($block->get('title') || $block->get('subtitle'))
<header class="text-center max-w-2xl mx-auto mb-10">
@if ($block->get('title'))
<h2 class="ec-heading text-2xl sm:text-3xl font-bold">{{ $block->get('title') }}</h2>
@endif
@if ($block->get('subtitle'))
<p class="ec-muted mt-3">{{ $block->get('subtitle') }}</p>
@endif
</header>
@endif
@include('website.blocks._header')
@if ($variant === 'marquee')
<div class="ec-marquee overflow-hidden" style="--marquee-duration:{{ $speed }}">
......@@ -26,6 +19,65 @@ class="h-12 w-auto object-contain shrink-0 transition {{ $gray }}">
@endforeach
</div>
</div>
@elseif ($variant === 'focus_carousel')
{{--
One logo at a time holds focus in the centre; the rest recede.
Built on native scroll-snap rather than a transform track, so it
still scrolls by touch, trackpad and keyboard if the timer never
runs — and a reader who prefers reduced motion simply gets a
scrollable row with nothing moving on its own.
--}}
<div x-data="{
active: 0,
total: {{ $items->count() }},
timer: null,
reduced: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
start() {
if (this.reduced || this.total < 2) return;
this.timer = setInterval(() => this.go((this.active + 1) % this.total), {{ $dwell }});
},
stop() { clearInterval(this.timer); this.timer = null; },
go(i) {
this.active = i;
const el = this.$refs.track?.children[i];
if (el) el.scrollIntoView({ inline: 'center', block: 'nearest', behavior: this.reduced ? 'auto' : 'smooth' });
},
}"
x-init="start()"
x-on:mouseenter="stop()" x-on:mouseleave="start()"
x-on:focusin="stop()" x-on:focusout="start()">
<div x-ref="track"
class="flex items-center gap-10 overflow-x-auto py-10 [scrollbar-width:none] [&::-webkit-scrollbar]{display:none}"
style="scroll-snap-type: x mandatory;">
@foreach ($items as $idx => $p)
@php $url = safe_url(data_get($p, 'url')); @endphp
<div class="ec-focus-item shrink-0" style="scroll-snap-align: center;"
x-bind:data-active="active === {{ $idx }} ? 'true' : 'false'">
<{{ $url ? 'a' : 'div' }}
@if ($url) href="{{ $url }}" target="_blank" rel="noopener noreferrer" @endif
class="ec-focus-tile flex h-24 w-40 items-center justify-center p-4">
<img src="{{ $logo($p) }}" alt="{{ $name($p) }}" loading="lazy"
class="max-h-full max-w-full object-contain">
</{{ $url ? 'a' : 'div' }}>
</div>
@endforeach
</div>
@if ($items->count() > 1)
<div class="mt-6 flex items-center justify-center gap-2">
@foreach ($items as $idx => $p)
<button type="button" x-on:click="stop(); go({{ $idx }}); start()"
class="h-1.5 rounded-full transition-all"
x-bind:class="active === {{ $idx }} ? 'w-6 bg-[var(--site-accent)]' : 'w-1.5 bg-current opacity-30'"
aria-label="{{ $name($p) ?: __('شعار') . ' ' . ($idx + 1) }}"></button>
@endforeach
</div>
@endif
</div>
@else
<div class="flex flex-wrap items-center justify-center gap-x-12 gap-y-8">
@foreach ($items as $p)
......
......@@ -3,33 +3,76 @@
$achievements = collect($block->get('achievements') ?: []);
$social = collect($block->get('social') ?: []);
$centered = in_array($variant, ['centered', 'quote_focus'], true);
$overlay = $variant === 'overlay_split';
$ruleAlign = ($block->get('header_align') ?? 'start') === 'center' ? 'center' : 'start';
$ruleClass = $block->get('heading_rule') === 'accent_bar'
? 'ec-heading-rule ec-heading-rule--'.$ruleAlign
: '';
@endphp
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
@if ($block->get('section_title'))
<h2 class="ec-heading text-3xl sm:text-4xl font-bold text-center mb-12">{{ $block->get('section_title') }}</h2>
@if ($block->get('eyebrow') || $block->get('section_title'))
<header class="mb-10 {{ $ruleAlign === 'center' ? 'text-center' : 'text-start' }}">
@if ($block->get('eyebrow'))
<p class="ec-eyebrow mb-3">{{ $block->get('eyebrow') }}</p>
@endif
@if ($block->get('section_title'))
<h2 class="ec-heading text-3xl sm:text-4xl font-black {{ $ruleClass }}">
{!! website_highlight($block->get('section_title')) !!}
</h2>
@endif
</header>
@endif
<div class="{{ $centered ? 'flex flex-col items-center text-center gap-6 max-w-3xl mx-auto' : 'grid md:grid-cols-[minmax(0,20rem)_1fr] gap-10 lg:gap-16 items-start' }}
<div class="{{ $centered ? 'flex flex-col items-center text-center gap-6 max-w-3xl mx-auto' : 'grid md:grid-cols-[minmax(0,22rem)_1fr] gap-10 lg:gap-16 items-start' }}
{{ $variant === 'card' ? 'ec-surface rounded-3xl p-8 shadow-sm' : '' }}">
<figure class="{{ $centered ? 'w-40' : 'w-full' }} shrink-0">
<figure class="{{ $centered ? 'w-40' : 'w-full' }} shrink-0 {{ $overlay ? 'relative overflow-hidden rounded-2xl' : '' }}">
<img src="{{ $block->get('photo') }}" alt="{{ $block->get('name') }}" loading="lazy"
class="w-full {{ $centered ? 'aspect-square rounded-full' : 'aspect-[3/4] rounded-2xl' }} object-cover">
class="w-full {{ $centered ? 'aspect-square rounded-full' : 'aspect-[4/5] rounded-2xl' }} object-cover">
@if ($overlay && ($block->get('name') || $block->get('role')))
{{-- Name and role sit on the portrait itself, so the person is
identified in the same glance as their face. --}}
<figcaption class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent p-5 pt-16">
@if ($block->get('name'))
<p class="text-white text-2xl font-bold leading-tight">{{ $block->get('name') }}</p>
@endif
@if ($block->get('role'))
<p class="ec-accent-text text-sm font-bold uppercase tracking-widest mt-1">{{ $block->get('role') }}</p>
@endif
</figcaption>
@endif
</figure>
<div class="flex flex-col gap-5 min-w-0">
<div class="flex flex-col gap-1">
@if ($block->get('badge'))
<span class="ec-accent self-start text-xs font-bold uppercase tracking-widest px-3 py-1 rounded-full border">{{ $block->get('badge') }}</span>
@endif
<h3 class="ec-heading text-2xl sm:text-3xl font-bold mt-2">{{ $block->get('name') }}</h3>
@if ($block->get('role'))
<p class="ec-accent font-medium">{{ $block->get('role') }}</p>
@endif
</div>
@unless ($overlay)
<div class="flex flex-col gap-1">
@if ($block->get('badge'))
<span class="ec-accent self-start text-xs font-bold uppercase tracking-widest px-3 py-1 rounded-full border">{{ $block->get('badge') }}</span>
@endif
<h3 class="ec-heading text-2xl sm:text-3xl font-bold mt-2">{{ $block->get('name') }}</h3>
@if ($block->get('role'))
<p class="ec-accent font-medium">{{ $block->get('role') }}</p>
@endif
</div>
@endunless
@if ($block->get('callout_label') || $block->get('callout_value'))
{{-- One credential worth pulling out of the list and framing. --}}
<div class="ec-surface self-start rounded-xl px-5 py-4 max-w-sm">
@if ($block->get('callout_label'))
<p class="ec-muted text-xs font-bold uppercase tracking-widest">{{ $block->get('callout_label') }}</p>
@endif
@if ($block->get('callout_value'))
<p class="ec-heading font-bold mt-1 leading-snug">{{ $block->get('callout_value') }}</p>
@endif
</div>
@endif
@if ($block->get('quote'))
<blockquote class="ec-muted italic text-lg leading-relaxed border-s-4 ps-4">{{ $block->get('quote') }}</blockquote>
<blockquote class="ec-muted italic text-lg leading-relaxed ec-accent-edge">{{ $block->get('quote') }}</blockquote>
@endif
@if ($block->get('bio'))
......@@ -53,10 +96,18 @@ class="w-full {{ $centered ? 'aspect-square rounded-full' : 'aspect-[3/4] rounde
@endif
@if ($achievements->isNotEmpty())
<ul class="flex flex-col gap-2">
<ul class="grid gap-x-8 gap-y-4 {{ $overlay ? 'sm:grid-cols-2' : '' }}">
@foreach ($achievements as $i => $a)
<li class="flex items-start gap-3">
<span class="ec-accent mt-2 w-1.5 h-1.5 rounded-full shrink-0 bg-current"></span>
@if ($overlay)
<span class="ec-accent mt-0.5 shrink-0 inline-flex h-5 w-5 items-center justify-center rounded-full border border-current" aria-hidden="true">
<svg class="h-3 w-3" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
</span>
@else
<span class="ec-accent mt-2 w-1.5 h-1.5 rounded-full shrink-0 bg-current"></span>
@endif
<span class="ec-muted leading-relaxed">{{ $block->get("achievements.{$i}.text") ?: data_get($a, 'text') }}</span>
</li>
@endforeach
......
@php
$imageFirst = $variant === 'image_start';
$ratio = ['square' => 'aspect-square', 'portrait' => 'aspect-[3/4]', 'landscape' => 'aspect-[4/3]', 'wide' => 'aspect-[16/9]', 'auto' => ''][$block->get('image_ratio', 'landscape')] ?? '';
// The rule follows the heading's own alignment so it never floats away
// from the text it underlines.
$ruleAlign = ($block->get('header_align') ?? 'start') === 'center' ? 'center' : 'start';
$ruleClass = $block->get('heading_rule') === 'accent_bar'
? 'ec-heading-rule ec-heading-rule--'.$ruleAlign
: '';
$frameClass = [
'offset_outline' => 'ec-frame-offset',
'border' => 'ec-frame-border',
'tilt' => 'ec-frame-tilt',
][$block->get('image_frame', 'none')] ?? '';
$bullets = collect($block->get('bullets') ?: []);
$buttons = collect($block->get('buttons') ?: []);
@endphp
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid md:grid-cols-2 gap-10 lg:gap-16 items-center">
<div class="{{ $imageFirst ? 'md:order-1' : 'md:order-2' }}">
<figure class="{{ $variant === 'overlap' ? 'md:-mb-16 shadow-2xl' : '' }}">
<figure class="{{ $frameClass }} {{ $variant === 'overlap' ? 'md:-mb-16 shadow-2xl' : '' }}">
<img src="{{ $block->get('image') }}" alt="{{ $block->get('title') }}"
loading="lazy" class="w-full {{ $ratio }} object-cover rounded-2xl">
@if ($block->get('image_caption'))
......@@ -19,9 +30,13 @@
@if ($block->get('eyebrow'))
<p class="ec-eyebrow text-sm font-semibold uppercase tracking-widest">{{ $block->get('eyebrow') }}</p>
@endif
<h2 class="ec-heading text-3xl sm:text-4xl font-bold">{{ $block->get('title') }}</h2>
<h2 class="ec-heading text-3xl sm:text-4xl font-bold {{ $ruleClass }}">
{!! website_highlight($block->get('title')) !!}
</h2>
@if ($block->get('body'))
<div class="ec-prose ec-muted leading-relaxed">{!! clean_html($block->get('body')) !!}</div>
<div class="ec-prose ec-muted leading-relaxed {{ $block->get('body_accent_edge') ? 'ec-accent-edge' : '' }}">
{!! clean_html($block->get('body')) !!}
</div>
@endif
@if ($bullets->isNotEmpty())
<ul class="flex flex-col gap-3">
......
@php
$ratio = ['16:9' => 'aspect-video', '4:3' => 'aspect-[4/3]', '1:1' => 'aspect-square', '21:9' => 'aspect-[21/9]'][$block->get('ratio', '16:9')] ?? 'aspect-video';
// The rule follows the heading's own alignment so it never floats away
// from the text it underlines.
$ruleAlign = ($block->get('header_align') ?? 'start') === 'center' ? 'center' : 'start';
$ruleClass = $block->get('heading_rule') === 'accent_bar'
? 'ec-heading-rule ec-heading-rule--'.$ruleAlign
: '';
$url = $block->get('url');
$embed = website_video_embed_url($url, [
'autoplay' => $block->get('autoplay') ? 1 : 0,
......@@ -14,9 +20,11 @@
<p class="ec-eyebrow text-sm font-semibold uppercase tracking-widest">{{ $block->get('eyebrow') }}</p>
@endif
@if ($block->get('title'))
<h2 class="ec-heading text-3xl sm:text-4xl font-bold">{{ $block->get('title') }}</h2>
<h2 class="ec-heading text-3xl sm:text-4xl font-bold {{ $ruleClass }}">
{!! website_highlight($block->get('title')) !!}
</h2>
@endif
@if ($block->get('quote') && $variant === 'with_quote')
@if ($block->get('quote') && $variant === 'split')
<blockquote class="ec-muted text-lg italic leading-relaxed mt-2">
{{ $block->get('quote') }}
@if ($block->get('quote_author'))
......@@ -45,7 +53,14 @@ class="w-full h-full" loading="lazy" allowfullscreen
</div>
</div>
@if ($block->get('quote') && $variant !== 'with_quote')
<blockquote class="ec-muted text-center text-lg italic max-w-3xl mx-auto mt-8">{{ $block->get('quote') }}</blockquote>
{{-- The quote reads as a response to what was just watched, so it sits
under the player rather than above it. --}}
@if ($block->get('quote') && $variant !== 'split')
<blockquote class="ec-muted text-center text-lg italic max-w-3xl mx-auto mt-8">
{{ $block->get('quote') }}
@if ($block->get('quote_author'))
<footer class="mt-2 text-sm not-italic font-semibold">— {{ $block->get('quote_author') }}</footer>
@endif
</blockquote>
@endif
</div>
......@@ -36,6 +36,19 @@
<p class="text-xs text-gray-500">{{ __('يظهر أبناء القسم واحدًا تلو الآخر بدلًا من الظهور دفعة واحدة') }}</p>
</div>
<div class="flex flex-col gap-1.5">
<label for="mo-ambient" class="text-sm font-medium">{{ __('حركة مستمرة') }}</label>
<select id="mo-ambient" wire:model="styleForm.ambient" class="w-full rounded-lg border px-3 py-2 text-sm">
@foreach ([
'none' => 'بدون', 'float' => 'طفو لأعلى وأسفل', 'glow' => 'توهج',
'pulse' => 'نبض خفيف', 'shimmer' => 'لمعان عابر',
] as $v => $l)
<option value="{{ $v }}">{{ __($l) }}</option>
@endforeach
</select>
<p class="text-xs text-gray-500">{{ __('حركة لا تتوقف تُطبَّق على القسم بالكامل — استخدمها بحذر') }}</p>
</div>
<div class="rounded-lg bg-gray-50 border p-3 text-xs text-gray-600">
{{ __('تُعطَّل كل الحركات تلقائيًا لمن يفضّل تقليل الحركة في إعدادات جهازه، وكذلك عند إيقاف الحركات من إعدادات الموقع.') }}
</div>
......
@php
/*
* Footer entry point.
*
* Like the navbar, $sections only exists on the legacy section site; builder
* pages pass none. Defaulting it here keeps one footer serving both.
*/
$sections = $sections ?? collect();
$navBase = request()->routeIs('website.show')
? ''
: route('website.show', ['slug' => $academy->slug]);
$footerStyle = $settings->footer_style ?? 'dark';
$validStyles = ['dark', 'light', 'accent', 'transparent'];
if (!in_array($footerStyle, $validStyles)) {
if (! in_array($footerStyle, $validStyles, true)) {
$footerStyle = 'dark';
}
@endphp
@include("website.footers.{$footerStyle}", [
'sections' => $sections,
'navBase' => $navBase,
'academy' => $academy,
'settings' => $settings,
])
@php
$navBase = $navBase ?? (request()->routeIs('website.show') ? '' : route('website.show', ['slug' => $academy->slug]));
$sections = $sections ?? collect();
$blocks = $settings->footer_blocks ?? [];
$columns = $settings->footer_columns ?? 4;
// The accent band below carries the copyright when it is configured; showing
// it here as well would print the same line twice.
$hasPoweredBar = filled($settings->footer_powered_by ?? null);
$isAr = app()->getLocale() === 'ar';
$academyName = $isAr ? ($academy->name_ar ?: $academy->name) : ($academy->name ?: $academy->name_ar);
$siteBlurb = $isAr
? ($settings->site_description ?: $settings->site_description_en)
: ($settings->site_description_en ?: $settings->site_description);
$gridClass = match($columns) {
2 => 'grid-cols-1 md:grid-cols-2',
3 => 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
......@@ -16,14 +27,14 @@
<div class="{{ count($blocks) <= 2 ? 'md:col-span-2' : '' }}">
<div class="flex items-center gap-3 mb-4">
@if($academy->logo_path)
<img src="{{ asset('storage/' . $academy->logo_path) }}" alt="{{ $academy->name_ar }}" class="h-12 w-12 rounded-lg object-contain">
<img src="{{ asset('storage/' . $academy->logo_path) }}" alt="{{ $academyName }}" class="h-12 w-12 rounded-lg object-contain">
@endif
<span class="text-xl font-bold {{ $textColor }}">{{ $academy->name_ar }}</span>
<span class="text-xl font-bold {{ $textColor }}">{{ $academyName }}</span>
</div>
@if(!empty($block['content']))
<p class="{{ $mutedColor }} leading-relaxed max-w-md">{{ $block['content'] }}</p>
@elseif($settings->site_description)
<p class="{{ $mutedColor }} leading-relaxed max-w-md">{{ Str::limit($settings->site_description, 200) }}</p>
@elseif($siteBlurb)
<p class="{{ $mutedColor }} leading-relaxed max-w-md">{{ Str::limit($siteBlurb, 200) }}</p>
@endif
</div>
@break
......@@ -53,6 +64,19 @@
</div>
@break
@case('menu')
{{-- A column of links from an authored menu, so the footer and the
navbar cannot drift apart as pages are added or renamed. --}}
<div>
<h4 class="{{ $textColor }} font-bold mb-4">{{ $block['title'] ?? '' }}</h4>
@include('website.partials.site-menu', [
'menuKey' => $block['menu_key'] ?? 'footer',
'layout' => 'stacked',
'linkClass' => $mutedColor.' hover:text-[var(--site-accent)] text-sm',
])
</div>
@break
@case('contact')
<div>
<h4 class="{{ $textColor }} font-bold mb-4">{{ $block['title'] ?? 'تواصل معنا' }}</h4>
......@@ -119,12 +143,12 @@ class="w-10 h-10 rounded-lg bg-white/5 border {{ $borderColor }} flex items-cent
<div class="lg:col-span-2">
<div class="flex items-center gap-3 mb-4">
@if($academy->logo_path)
<img src="{{ asset('storage/' . $academy->logo_path) }}" alt="{{ $academy->name_ar }}" class="h-12 w-12 rounded-lg object-contain">
<img src="{{ asset('storage/' . $academy->logo_path) }}" alt="{{ $academyName }}" class="h-12 w-12 rounded-lg object-contain">
@endif
<span class="text-xl font-bold {{ $textColor }}">{{ $academy->name_ar }}</span>
<span class="text-xl font-bold {{ $textColor }}">{{ $academyName }}</span>
</div>
@if($settings->site_description)
<p class="{{ $mutedColor }} leading-relaxed max-w-md">{{ Str::limit($settings->site_description, 200) }}</p>
@if($siteBlurb)
<p class="{{ $mutedColor }} leading-relaxed max-w-md">{{ Str::limit($siteBlurb, 200) }}</p>
@endif
@if(is_array($settings->social_links ?? null) && count(array_filter($settings->social_links)))
<div class="flex items-center gap-3 mt-6">
......@@ -179,11 +203,13 @@ class="w-10 h-10 rounded-lg bg-white/5 border {{ $borderColor }} flex items-cent
</div>
{{-- Bottom Bar --}}
<div class="mt-12 pt-8 {{ $borderColor }} border-t flex flex-col sm:flex-row items-center justify-between gap-4">
<p class="{{ $mutedColor }} text-sm">
{{ $settings->footer_bottom_text ?? '&copy; ' . date('Y') . ' ' . $academy->name_ar . '. جميع الحقوق محفوظة.' }}
</p>
<p class="{{ $mutedColor }} opacity-60 text-xs">
مدعوم بنظام El Captain
</p>
</div>
@unless ($hasPoweredBar)
<div class="mt-12 pt-8 {{ $borderColor }} border-t flex flex-col sm:flex-row items-center justify-between gap-4">
<p class="{{ $mutedColor }} text-sm">
{{ $settings->footer_bottom_text ?: '© '.date('Y').' '.$academyName.'. '.__('جميع الحقوق محفوظة.') }}
</p>
<p class="{{ $mutedColor }} opacity-60 text-xs">
{{ __('مدعوم بنظام El Captain') }}
</p>
</div>
@endunless
{{--
The accent band that closes the page: copyright on the leading edge, a
"powered by" lock-up of partner logos on the trailing edge.
Rendered only when website_settings.footer_powered_by holds something, so a
site that never configures it keeps the plain bottom bar it has today.
Shape:
{
"label": {"ar": "بدعم من", "en": "Powered By"},
"copyright": {"ar": "...", "en": "..."},
"logos": [{"src": "...", "name": "...", "url": "..."}]
}
--}}
@php
$poweredBy = $settings->footer_powered_by ?? null;
$logos = collect(data_get($poweredBy, 'logos') ?: [])
->filter(fn ($l) => filled(data_get($l, 'src')))
->values();
$pick = function ($value) {
if (! is_array($value)) {
return $value;
}
$primary = app()->getLocale() === 'ar' ? 'ar' : 'en';
$fallback = $primary === 'ar' ? 'en' : 'ar';
return filled($value[$primary] ?? null) ? $value[$primary] : ($value[$fallback] ?? null);
};
$label = $pick(data_get($poweredBy, 'label'));
$copyright = $pick(data_get($poweredBy, 'copyright')) ?: $settings->footer_bottom_text;
@endphp
@if ($poweredBy && ($logos->isNotEmpty() || filled($copyright)))
<div class="ec-powered-bar" style="background: var(--site-accent)">
<div class="max-w-7xl mx-auto flex flex-col items-center justify-between gap-4 px-4 py-4 sm:flex-row sm:px-6 lg:px-8">
@if (filled($copyright))
<p class="text-sm font-medium" style="color: var(--site-primary)">{{ $copyright }}</p>
@endif
@if ($logos->isNotEmpty())
<div class="flex flex-wrap items-center justify-center gap-4 sm:gap-6">
@if (filled($label))
<span class="text-sm font-bold" style="color: var(--site-primary)">{{ $label }}</span>
@endif
@foreach ($logos as $logo)
@php $logoUrl = safe_url(data_get($logo, 'url')); @endphp
<{{ $logoUrl ? 'a' : 'span' }}
@if ($logoUrl) href="{{ $logoUrl }}" target="_blank" rel="noopener noreferrer" @endif
class="inline-flex items-center">
<img src="{{ data_get($logo, 'src') }}"
alt="{{ data_get($logo, 'name') ?: '' }}"
loading="lazy"
class="h-8 w-auto max-w-[7rem] object-contain">
</{{ $logoUrl ? 'a' : 'span' }}>
@endforeach
</div>
@endif
</div>
</div>
@endif
......@@ -2,4 +2,5 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
@include('website.footers._blocks', ['textColor' => 'text-white', 'mutedColor' => 'text-white/70', 'borderColor' => 'border-white/20'])
</div>
@include('website.footers._powered_by')
</footer>
......@@ -2,4 +2,5 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
@include('website.footers._blocks', ['textColor' => 'text-white', 'mutedColor' => 'text-white/60', 'borderColor' => 'border-white/10'])
</div>
@include('website.footers._powered_by')
</footer>
......@@ -2,4 +2,5 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
@include('website.footers._blocks', ['textColor' => 'text-[var(--site-text)]', 'mutedColor' => 'text-[var(--site-muted)]', 'borderColor' => 'border-[var(--site-border)]'])
</div>
@include('website.footers._powered_by')
</footer>
......@@ -2,4 +2,5 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
@include('website.footers._blocks', ['textColor' => 'text-[var(--site-text)]', 'mutedColor' => 'text-[var(--site-muted)]', 'borderColor' => 'border-[var(--site-border)]'])
</div>
@include('website.footers._powered_by')
</footer>
@php
// The public site is addressable per language, so direction, titles and the
// alternates below all follow the request's locale rather than a hardcoded
// Arabic default.
$locale = in_array(app()->getLocale(), website_locales(), true) ? app()->getLocale() : website_locales()[0];
$isRtl = $locale === 'ar';
$siteTitle = $locale === 'ar'
? ($settings->site_title ?: $settings->site_title_en ?: $academy->name_ar)
: ($settings->site_title_en ?: $settings->site_title ?: $academy->name);
$siteDescription = $locale === 'ar'
? ($settings->site_description ?: $settings->site_description_en ?: '')
: ($settings->site_description_en ?: $settings->site_description ?: '');
$pageSlug = website_strip_locale(request()->path());
@endphp
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<html lang="{{ $locale }}" dir="{{ $isRtl ? 'rtl' : 'ltr' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $settings->site_title ?? $academy->name_ar }}</title>
<meta name="description" content="{{ $settings->site_description ?? '' }}">
<title>{{ $siteTitle }}</title>
<meta name="description" content="{{ $siteDescription }}">
{{-- SEO --}}
<meta name="robots" content="index, follow">
<link rel="canonical" href="{{ url()->current() }}">
<link rel="canonical" href="{{ website_url($pageSlug, $locale) }}">
@foreach (website_locales() as $alt)
<link rel="alternate" hreflang="{{ $alt }}" href="{{ website_url($pageSlug, $alt) }}">
@endforeach
<link rel="alternate" hreflang="x-default" href="{{ website_url($pageSlug, website_locales()[0]) }}">
@if($academy->logo_path)
<link rel="icon" type="image/png" href="{{ asset('storage/' . $academy->logo_path) }}">
<link rel="apple-touch-icon" href="{{ asset('storage/' . $academy->logo_path) }}">
@endif
{{-- Open Graph --}}
<meta property="og:title" content="{{ $settings->site_title ?? $academy->name_ar }}">
<meta property="og:description" content="{{ $settings->site_description ?? '' }}">
<meta property="og:url" content="{{ url()->current() }}">
<meta property="og:title" content="{{ $siteTitle }}">
<meta property="og:description" content="{{ $siteDescription }}">
<meta property="og:url" content="{{ website_url($pageSlug, $locale) }}">
<meta property="og:type" content="website">
<meta property="og:locale" content="ar_EG">
<meta property="og:locale" content="{{ $locale === 'ar' ? 'ar_EG' : 'en_US' }}">
@if($academy->logo_path)
<meta property="og:image" content="{{ asset('storage/' . $academy->logo_path) }}">
@endif
{{-- Twitter Card --}}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $settings->site_title ?? $academy->name_ar }}">
<meta name="twitter:description" content="{{ $settings->site_description ?? '' }}">
<meta name="twitter:title" content="{{ $siteTitle }}">
<meta name="twitter:description" content="{{ $siteDescription }}">
@if($academy->logo_path)
<meta name="twitter:image" content="{{ asset('storage/' . $academy->logo_path) }}">
@endif
......
@php
/*
* Navbar entry point.
*
* $sections only exists on the legacy section-rendered site; builder pages
* render from website_blocks and pass no sections at all. Defaulting it here
* is what lets one navbar serve both — without it every builder page fails
* with "Undefined variable $sections" before a single block is rendered.
*/
$sections = $sections ?? collect();
$navBase = request()->routeIs('website.show')
? ''
: route('website.show', ['slug' => $academy->slug]);
$navbarStyle = $settings->navbar_style ?? 'transparent';
$validStyles = ['solid', 'transparent', 'floating', 'centered', 'minimal'];
if (!in_array($navbarStyle, $validStyles)) {
$validStyles = ['solid', 'transparent', 'glass', 'floating', 'centered', 'minimal'];
if (! in_array($navbarStyle, $validStyles, true)) {
$navbarStyle = 'solid';
}
@endphp
@include("website.navbars.{$navbarStyle}", [
'sections' => $sections,
'navBase' => $navBase,
'academy' => $academy,
'settings' => $settings,
])
{{--
The controls on the trailing edge of every navbar: language, dashboard link
for signed-in staff, the call-to-action, and the mobile menu trigger.
Shared so a change to the CTA or the language switch lands in every navbar
style at once instead of five near-identical copies drifting apart.
@param string $linkClass colour/typography for text controls
@param string $ctaClass extra classes for the CTA button
@param bool $compact tighter spacing for the floating navbar
--}}
@php
$linkClass = $linkClass ?? 'text-white/85 hover:text-white';
$ctaClass = $ctaClass ?? '!py-2 !px-4';
$compact = $compact ?? false;
$ctaHref = safe_url($settings->navbar_cta_link ?? null) ?: ($navBase.'#section-contact');
$ctaText = $settings->navbar_cta_text ?: __('سجّل الآن');
@endphp
<div class="flex items-center {{ $compact ? 'gap-1.5' : 'gap-2 sm:gap-3' }}">
@if ($settings->navbar_show_language ?? false)
<div class="hidden sm:block">
@include('website.partials.language-switcher', ['linkClass' => $linkClass])
</div>
@endif
@auth
<a href="{{ route('dashboard') }}"
class="hidden sm:inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium transition {{ $linkClass }}">
{{ __('لوحة التحكم') }}
</a>
@endauth
@if (filled($ctaText))
<a href="{{ $ctaHref }}" class="hidden sm:inline-flex site-btn site-btn--primary text-sm {{ $ctaClass }}">
{{ $ctaText }}
</a>
@endif
<button type="button"
class="lg:hidden rounded-lg p-2 transition hover:bg-white/10 {{ $linkClass }}"
onclick="document.getElementById('mobile-menu').classList.toggle('hidden')"
aria-label="{{ __('القائمة') }}" aria-controls="mobile-menu">
<svg class="{{ $compact ? 'w-5 h-5' : 'w-6 h-6' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</div>
{{-- Shared mobile menu, included by all navbar variants --}}
@php $navBase = $navBase ?? (request()->routeIs('website.show') ? '' : route('website.show', ['slug' => $academy->slug])); @endphp
{{-- Shared mobile menu, included by every navbar variant. --}}
<div id="mobile-menu" class="hidden lg:hidden bg-black/95 backdrop-blur-lg border-t border-white/10">
<div class="px-4 py-4 space-y-1">
@foreach($sections as $section)
<a href="{{ $navBase }}#section-{{ $section->section_key->value }}"
class="block px-4 py-3 text-white/80 hover:text-white hover:bg-white/5 rounded-lg transition-colors"
onclick="document.getElementById('mobile-menu').classList.add('hidden')">
{{ $section->title ?? $section->section_key->label() }}
</a>
@endforeach
@include('website.partials.site-menu', [
'menuKey' => 'primary',
'layout' => 'stacked',
'linkClass' => 'text-white/85 hover:text-white w-full',
])
<div class="border-t border-white/10 pt-3 mt-3 space-y-1">
@if ($settings->navbar_show_language ?? false)
@foreach (website_locales() as $loc)
<a href="{{ website_current_url_in($loc) }}" hreflang="{{ $loc }}"
class="block rounded-lg px-4 py-3 text-white/80 transition hover:bg-white/5 hover:text-white
{{ $loc === app()->getLocale() ? 'font-semibold' : '' }}">
{{ ['ar' => 'العربية', 'en' => 'English'][$loc] ?? $loc }}
</a>
@endforeach
@endif
<div class="border-t border-white/10 pt-3 mt-3">
@auth
<a href="{{ route('dashboard') }}" class="block px-4 py-3 text-white/80 hover:text-white hover:bg-white/5 rounded-lg transition-colors">
<a href="{{ route('dashboard') }}" class="block rounded-lg px-4 py-3 text-white/80 transition hover:bg-white/5 hover:text-white">
{{ __('لوحة التحكم') }}
</a>
@else
<a href="{{ route('login') }}" class="block px-4 py-3 text-white/80 hover:text-white hover:bg-white/5 rounded-lg transition-colors">
<a href="{{ route('login') }}" class="block rounded-lg px-4 py-3 text-white/80 transition hover:bg-white/5 hover:text-white">
{{ __('تسجيل الدخول') }}
</a>
@endauth
......
@php $navBase = request()->routeIs('website.show') ? '' : route('website.show', ['slug' => $academy->slug]); @endphp
{{--
Logo in the middle, navigation split either side of it.
The split is by count rather than by two separate menus, so an editor
authors one primary menu and this style arranges it.
--}}
@php
$allItems = app(\App\Domain\Website\Services\WebsiteMenuService::class)->tree('primary');
$half = (int) ceil($allItems->count() / 2);
@endphp
<nav class="site-nav site-nav--solid" id="site-navbar">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-20">
{{-- Links Start --}}
<div class="hidden lg:flex items-center gap-1 flex-1 justify-end">
@foreach($sections->take(3) as $navSection)
<a href="{{ $navBase }}#section-{{ $navSection->section_key->value }}" class="px-3 py-2 text-sm font-medium text-white/80 hover:text-white rounded-lg hover:bg-white/5 transition-all">
{{ $navSection->title ?? $navSection->section_key->label() }}
</a>
@endforeach
<div class="flex items-center justify-between h-20 gap-4">
<div class="hidden lg:flex flex-1 justify-end">
@include('website.partials.site-menu', ['menuKey' => 'primary', 'menuSlice' => ['skip' => 0, 'take' => $half]])
</div>
{{-- Centered Logo --}}
<a href="{{ $navBase }}#section-hero" class="flex items-center gap-3 group mx-8">
@if($academy->logo_path)
<img src="{{ asset('storage/' . $academy->logo_path) }}" alt="{{ $academy->name_ar }}" class="h-12 w-12 rounded-xl object-contain transition-transform group-hover:scale-110">
@endif
<span class="text-lg font-bold text-white hidden sm:block">{{ $academy->name_ar }}</span>
</a>
<div class="mx-6">
@include('website.partials.site-logo')
</div>
{{-- Links End --}}
<div class="hidden lg:flex items-center gap-1 flex-1">
@foreach($sections->skip(3)->take(3) as $navSection)
<a href="{{ $navBase }}#section-{{ $navSection->section_key->value }}" class="px-3 py-2 text-sm font-medium text-white/80 hover:text-white rounded-lg hover:bg-white/5 transition-all">
{{ $navSection->title ?? $navSection->section_key->label() }}
</a>
@endforeach
<a href="{{ safe_url($settings->navbar_cta_link ?? null) ?: ($navBase . '#section-contact') }}" class="ms-2 site-btn site-btn--primary text-sm !py-1.5 !px-4">
{{ $settings->navbar_cta_text ?? 'سجّل الآن' }}
</a>
<div class="hidden lg:flex flex-1 items-center gap-3">
@include('website.partials.site-menu', ['menuKey' => 'primary', 'menuSlice' => ['skip' => $half, 'take' => 99]])
</div>
<button type="button" class="lg:hidden p-2 rounded-lg text-white/80 hover:text-white hover:bg-white/10 transition-colors" onclick="document.getElementById('mobile-menu').classList.toggle('hidden')" aria-label="القائمة">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
@include('website.navbars._actions')
</div>
</div>
@include('website.navbars._mobile_menu')
......
@php $navBase = request()->routeIs('website.show') ? '' : route('website.show', ['slug' => $academy->slug]); @endphp
<nav class="site-nav site-nav--floating" id="site-navbar">
<div class="max-w-7xl mx-auto px-4 sm:px-6">
<div class="flex items-center justify-between h-16">
<a href="{{ $navBase }}#section-hero" class="flex items-center gap-3 group">
@if($academy->logo_path)
<img src="{{ asset('storage/' . $academy->logo_path) }}" alt="{{ $academy->name_ar }}" class="h-9 w-9 rounded-lg object-contain transition-transform group-hover:scale-110">
@endif
<span class="text-base font-bold text-white">{{ $academy->name_ar }}</span>
</a>
<div class="flex items-center justify-between h-16 gap-3">
@include('website.partials.site-logo')
<div class="hidden lg:flex items-center gap-1">
@foreach($sections->take(6) as $navSection)
<a href="{{ $navBase }}#section-{{ $navSection->section_key->value }}" class="px-3 py-1.5 text-sm font-medium text-white/80 hover:text-white rounded-lg hover:bg-white/10 transition-all">
{{ $navSection->title ?? $navSection->section_key->label() }}
</a>
@endforeach
<div class="hidden lg:flex items-center">
@include('website.partials.site-menu', ['menuKey' => 'primary'])
</div>
<div class="flex items-center gap-2">
<a href="{{ safe_url($settings->navbar_cta_link ?? null) ?: ($navBase . '#section-contact') }}" class="hidden sm:inline-flex site-btn site-btn--primary text-sm !py-1.5 !px-4 !rounded-full">
{{ $settings->navbar_cta_text ?? 'سجّل الآن' }}
</a>
<button type="button" class="lg:hidden p-2 rounded-lg text-white/80 hover:text-white hover:bg-white/10 transition-colors" onclick="document.getElementById('mobile-menu').classList.toggle('hidden')" aria-label="القائمة">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
</div>
@include('website.navbars._actions', [
'ctaClass' => '!py-1.5 !px-4 !rounded-full',
'compact' => true,
])
</div>
</div>
@include('website.navbars._mobile_menu')
......
{{--
Frosted navbar: translucent, blurred, with a hairline edge the bar reads
as a pane of glass over the hero rather than a solid band above it.
--}}
<nav class="site-nav site-nav--glass" id="site-navbar">
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-20 gap-4">
@include('website.partials.site-logo')
<div class="hidden lg:flex items-center justify-center flex-1">
@include('website.partials.site-menu', ['menuKey' => 'primary'])
</div>
@include('website.navbars._actions', ['ctaClass' => '!py-2 !px-5 !rounded-lg'])
</div>
</div>
@include('website.navbars._mobile_menu')
</nav>
@php $navBase = request()->routeIs('website.show') ? '' : route('website.show', ['slug' => $academy->slug]); @endphp
{{-- Brand and actions only: no navigation links at all. --}}
<nav class="site-nav site-nav--transparent" id="site-navbar">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-20">
<a href="{{ $navBase }}#section-hero" class="flex items-center gap-3 group">
@if($academy->logo_path)
<img src="{{ asset('storage/' . $academy->logo_path) }}" alt="{{ $academy->name_ar }}" class="h-10 w-10 rounded-lg object-contain transition-transform group-hover:scale-110">
@endif
<span class="text-lg font-bold text-white">{{ $academy->name_ar }}</span>
</a>
<div class="flex items-center gap-3">
@auth
<a href="{{ route('dashboard') }}" class="hidden sm:inline-flex items-center gap-1 px-3 py-2 text-sm font-medium text-white/70 hover:text-white transition-colors">
{{ __('لوحة التحكم') }}
</a>
@endauth
<a href="{{ safe_url($settings->navbar_cta_link ?? null) ?: ($navBase . '#section-contact') }}" class="site-btn site-btn--primary text-sm !py-2 !px-5">
{{ $settings->navbar_cta_text ?? 'سجّل الآن' }}
</a>
<button type="button" class="lg:hidden p-2 rounded-lg text-white/80 hover:text-white hover:bg-white/10 transition-colors" onclick="document.getElementById('mobile-menu').classList.toggle('hidden')" aria-label="القائمة">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
</div>
<div class="flex items-center justify-between h-20 gap-4">
@include('website.partials.site-logo')
@include('website.navbars._actions', ['ctaClass' => '!py-2 !px-5'])
</div>
</div>
@include('website.navbars._mobile_menu')
......
@php $navBase = request()->routeIs('website.show') ? '' : route('website.show', ['slug' => $academy->slug]); @endphp
<nav class="site-nav site-nav--solid" id="site-navbar">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-20">
<a href="{{ $navBase }}#section-hero" class="flex items-center gap-3 group">
@if($academy->logo_path)
<img src="{{ asset('storage/' . $academy->logo_path) }}" alt="{{ $academy->name_ar }}" class="h-10 w-10 rounded-lg object-contain transition-transform group-hover:scale-110">
@endif
<span class="text-lg font-bold text-white">{{ $academy->name_ar }}</span>
</a>
<div class="flex items-center justify-between h-20 gap-4">
@include('website.partials.site-logo')
<div class="hidden lg:flex items-center gap-1">
@foreach($sections->take(7) as $navSection)
<a href="{{ $navBase }}#section-{{ $navSection->section_key->value }}" class="px-3 py-2 text-sm font-medium text-white/80 hover:text-white rounded-lg hover:bg-white/5 transition-all">
{{ $navSection->title ?? $navSection->section_key->label() }}
</a>
@endforeach
<div class="hidden lg:flex items-center">
@include('website.partials.site-menu', ['menuKey' => 'primary'])
</div>
<div class="flex items-center gap-3">
<a href="{{ safe_url($settings->navbar_cta_link ?? null) ?: ($navBase . '#section-contact') }}" class="hidden sm:inline-flex site-btn site-btn--primary text-sm !py-2 !px-4">
{{ $settings->navbar_cta_text ?? 'سجّل الآن' }}
</a>
@auth
<a href="{{ route('dashboard') }}" class="hidden sm:inline-flex items-center gap-1 px-3 py-2 text-sm font-medium text-white/80 hover:text-white rounded-lg hover:bg-white/10 transition-all">
{{ __('لوحة التحكم') }}
</a>
@endauth
<button type="button" class="lg:hidden p-2 rounded-lg text-white/80 hover:text-white hover:bg-white/10 transition-colors" onclick="document.getElementById('mobile-menu').classList.toggle('hidden')" aria-label="القائمة">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
</div>
@include('website.navbars._actions')
</div>
</div>
@include('website.navbars._mobile_menu')
......
@php $navBase = request()->routeIs('website.show') ? '' : route('website.show', ['slug' => $academy->slug]); @endphp
<nav class="site-nav site-nav--transparent" id="site-navbar">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-20">
<a href="{{ $navBase }}#section-hero" class="flex items-center gap-3 group">
@if($academy->logo_path)
<img src="{{ asset('storage/' . $academy->logo_path) }}" alt="{{ $academy->name_ar }}" class="h-10 w-10 rounded-lg object-contain transition-transform group-hover:scale-110">
@endif
<span class="text-lg font-bold text-white">{{ $academy->name_ar }}</span>
</a>
<div class="flex items-center justify-between h-20 gap-4">
@include('website.partials.site-logo')
<div class="hidden lg:flex items-center gap-1">
@foreach($sections->take(7) as $navSection)
<a href="{{ $navBase }}#section-{{ $navSection->section_key->value }}" class="px-3 py-2 text-sm font-medium text-white/80 hover:text-white rounded-lg hover:bg-white/5 transition-all">
{{ $navSection->title ?? $navSection->section_key->label() }}
</a>
@endforeach
<div class="hidden lg:flex items-center">
@include('website.partials.site-menu', ['menuKey' => 'primary'])
</div>
<div class="flex items-center gap-3">
<a href="{{ safe_url($settings->navbar_cta_link ?? null) ?: ($navBase . '#section-contact') }}" class="hidden sm:inline-flex site-btn site-btn--primary text-sm !py-2 !px-4">
{{ $settings->navbar_cta_text ?? 'سجّل الآن' }}
</a>
@auth
<a href="{{ route('dashboard') }}" class="hidden sm:inline-flex items-center gap-1 px-3 py-2 text-sm font-medium text-white/80 hover:text-white rounded-lg hover:bg-white/10 transition-all">
{{ __('لوحة التحكم') }}
</a>
@endauth
<button type="button" class="lg:hidden p-2 rounded-lg text-white/80 hover:text-white hover:bg-white/10 transition-colors" onclick="document.getElementById('mobile-menu').classList.toggle('hidden')" aria-label="القائمة">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
</div>
@include('website.navbars._actions')
</div>
</div>
@include('website.navbars._mobile_menu')
......
{{--
Switches the page's language while staying on the same page.
Each option is a real link to the same content under the other locale's
prefix, so the control is crawlable, shareable and works without JavaScript
a <button> that posts a session change would do none of those.
@param string $linkClass colour/typography for the trigger
--}}
@php
$linkClass = $linkClass ?? 'text-white/85 hover:text-white';
$current = in_array(app()->getLocale(), website_locales(), true) ? app()->getLocale() : website_locales()[0];
$names = ['ar' => 'العربية', 'en' => 'English'];
@endphp
<div class="relative" x-data="{ open: false }" x-on:mouseleave="open = false">
<button type="button"
x-on:click="open = !open" x-on:mouseenter="open = true"
x-bind:aria-expanded="open ? 'true' : 'false'"
class="ec-nav-item inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium transition {{ $linkClass }}"
aria-label="{{ __('تغيير اللغة') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0zM3.6 9h16.8M3.6 15h16.8M12 3a15 15 0 010 18M12 3a15 15 0 000 18"/>
</svg>
<span class="relative z-10">{{ $names[$current] ?? $current }}</span>
<svg class="w-3.5 h-3.5 transition-transform" x-bind:class="open && 'rotate-180'"
fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<ul x-show="open" x-cloak x-transition.opacity
x-on:keydown.escape.window="open = false"
class="ec-nav-dropdown absolute z-50 mt-1 min-w-40 end-0 rounded-xl p-2 shadow-xl">
@foreach (website_locales() as $loc)
<li>
<a href="{{ website_current_url_in($loc) }}" hreflang="{{ $loc }}"
@if ($loc === $current) aria-current="true" @endif
class="flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm transition hover:bg-white/10
{{ $loc === $current ? 'font-semibold' : '' }}">
<span>{{ $names[$loc] ?? $loc }}</span>
@if ($loc === $current)
<svg class="w-4 h-4 ec-accent-text" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
@endif
</a>
</li>
@endforeach
</ul>
</div>
{{--
The site's brand lock-up: the academy logo, optionally followed by a second
logo separated by a hairline rule (a parent company or co-brand).
Links to the homepage in the reader's current language, so the brand mark is
never the control that silently switches locale.
@param string $textClass colour for the wordmark beside the logo
--}}
@php
$textClass = $textClass ?? 'text-white';
$secondary = $settings->navbar_secondary_logo_path ?? null;
$academyName = app()->getLocale() === 'ar'
? ($academy->name_ar ?: $academy->name)
: ($academy->name ?: $academy->name_ar);
@endphp
<a href="{{ website_url() }}" class="flex items-center gap-3 group shrink-0" aria-label="{{ $academyName }}">
@if ($academy->logo_path)
<img src="{{ asset('storage/'.$academy->logo_path) }}"
alt="{{ $academyName }}"
class="h-11 w-auto max-w-[9rem] object-contain transition-transform group-hover:scale-105">
@else
<span class="text-lg font-bold {{ $textClass }}">{{ $academyName }}</span>
@endif
@if ($secondary)
<span class="h-8 w-px bg-current opacity-25" aria-hidden="true"></span>
<img src="{{ asset('storage/'.$secondary) }}" alt="" aria-hidden="true"
class="h-9 w-auto max-w-[9rem] object-contain">
@endif
</a>
{{--
The public navigation, rendered from the authored menu tree.
Falls back to the legacy section anchors when no menu has been authored, so
a tenant that never opened the menu manager keeps the navigation it has.
Items whose target cannot be resolved are skipped rather than rendered as a
dead link the project forbids href="#" placeholders.
@param string $menuKey which menu to render (default 'primary')
@param string $layout 'horizontal' | 'stacked'
@param string $linkClass colour/typography for a top-level item
@param array $menuSlice ['skip' => int, 'take' => int] to render part of
the menu, used by the centered navbar to split one
authored menu either side of the logo
--}}
@php
$menuKey = $menuKey ?? 'primary';
$layout = $layout ?? 'horizontal';
$linkClass = $linkClass ?? 'text-white/85 hover:text-white';
$stacked = $layout === 'stacked';
$items = app(\App\Domain\Website\Services\WebsiteMenuService::class)->tree($menuKey);
if (! empty($menuSlice)) {
$items = $items->slice((int) ($menuSlice['skip'] ?? 0), (int) ($menuSlice['take'] ?? 99))->values();
}
// Legacy tenants have sections but no menu; their nav is section anchors.
$legacySections = ($items->isEmpty() && isset($sections)) ? collect($sections)->take(7) : collect();
@endphp
@if ($items->isNotEmpty())
<ul class="flex {{ $stacked ? 'flex-col gap-1' : 'items-center gap-1 lg:gap-2' }}">
@foreach ($items as $item)
@php
$href = $item->href();
$kids = $item->children->where('is_visible', true);
@endphp
@continue (! $href && $kids->isEmpty())
<li class="relative" @if ($kids->isNotEmpty()) x-data="{ open: false }" x-on:mouseleave="open = false" @endif>
@if ($kids->isNotEmpty())
<button type="button"
x-on:click="open = !open" x-on:mouseenter="open = true"
x-bind:aria-expanded="open ? 'true' : 'false'"
class="ec-nav-item inline-flex items-center gap-1.5 rounded-lg px-3 py-2 font-medium transition {{ $linkClass }}">
@if ($item->icon)<x-website.icon :name="$item->icon" class="w-4 h-4" />@endif
<span class="relative z-10">{{ $item->localizedLabel() }}</span>
<svg class="w-3.5 h-3.5 transition-transform" x-bind:class="open && 'rotate-180'"
fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<ul x-show="open" x-cloak x-transition.opacity
x-on:keydown.escape.window="open = false"
class="ec-nav-dropdown {{ $stacked
? 'static ms-4 mt-1 space-y-1'
: 'absolute z-50 mt-1 min-w-56 start-0 rounded-xl p-2 shadow-xl' }}">
@foreach ($kids as $child)
@php $childHref = $child->href(); @endphp
@continue (! $childHref)
<li>
<a href="{{ $childHref }}"
@if ($child->open_in_new_tab) target="_blank" rel="noopener noreferrer" @endif
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition hover:bg-white/10">
@if ($child->icon)<x-website.icon :name="$child->icon" class="w-4 h-4" />@endif
{{ $child->localizedLabel() }}
</a>
</li>
@endforeach
</ul>
@else
<a href="{{ $href }}"
@if ($item->open_in_new_tab) target="_blank" rel="noopener noreferrer" @endif
class="ec-nav-item inline-flex items-center gap-1.5 rounded-lg px-3 py-2 font-medium transition
{{ $item->highlight ? 'ec-btn ec-btn--primary rounded-full px-5' : $linkClass }}">
@if ($item->icon)<x-website.icon :name="$item->icon" class="w-4 h-4" />@endif
<span class="relative z-10">{{ $item->localizedLabel() }}</span>
</a>
@endif
</li>
@endforeach
</ul>
@elseif ($legacySections->isNotEmpty())
<ul class="flex {{ $stacked ? 'flex-col gap-1' : 'items-center gap-1 lg:gap-2' }}">
@foreach ($legacySections as $navSection)
<li>
<a href="{{ ($navBase ?? '') }}#section-{{ $navSection->section_key->value }}"
class="ec-nav-item inline-flex rounded-lg px-3 py-2 text-sm font-medium transition {{ $linkClass }}">
<span class="relative z-10">{{ $navSection->title ?? $navSection->section_key->label() }}</span>
</a>
</li>
@endforeach
</ul>
@endif
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment