Commit 21792905 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(website): make the builder able to reproduce a real bilingual site

Building an existing client site inside the builder surfaced the gaps between
"the blocks exist" and "the blocks can express a finished design". Each of these
was found by rendering the target and comparing it, not by reading the code.

Blueprint export/import (v2) now carries the whole design, not a third of it.
It exported pages only — so importing a design gave you the content with a
default theme and no navigation, which looks nothing like its source. It now
carries theme settings and menus too. Menu items record their page by SLUG,
because a page id means nothing in another tenant's database and would import a
navigation pointing at whatever happened to hold that id. Tracking identifiers
are deliberately excluded from the whitelist: importing a design must never
start reporting one client's traffic into another's account. v1 files still
import.

Isolation that did not isolate. BlockRenderer catches a failing block so the
rest of the site survives, but Laravel's View::render() calls flushState() when
any view throws, which clears the section stack of the page *around* it.
Rendered inside the layout's @section, one bad block therefore killed the whole
page at @endsection with an unrelated "Cannot end a section" error — the exact
opposite of the intent. Blocks are now rendered before the layout runs, where
there is no open section to corrupt.

Bilingual content reached templates raw. Translatable repeater sub-fields
(a button label, a card title, a partner name) are stored as ['ar'=>…,'en'=>…]
and read straight out of the data array, so they arrived at {{ }} as arrays and
took the block down with "htmlspecialchars(): array given". website_text()
resolves them, and 49 such reads across 15 block views now use it.

The English site rendered right-to-left. website.css hardcoded
`direction: rtl` on .website-body, silently overriding the dir attribute the
layout computes from the locale. Direction now follows the document.

There was no English at all. No lang/ directory existed, so every __() returned
its Arabic key and English visitors read Arabic form labels, buttons and
helper text. lang/en.json covers all 125 public-site strings.

Smaller gaps, each of which made a real design impossible to express:
- 'glass' was a valid navbar template and a forbidden column value; the CHECK
  constraint predated it, so choosing it failed at write time.
- navbar_cta_text had no English twin, so a bilingual site showed one language's
  button to both audiences.
- An empty navbar CTA fell back to the default label, so the button could not
  be turned off.
- An anchor menu item resolved to a bare "#id", which points at nothing from a
  sub-page; it now addresses the homepage in the reader's language.
- A footer column title was a plain string, so it could not be bilingual; the
  'about' column dropped the social row whenever columns were configured.
- product_showcase stacked its showcase under a centred headline instead of
  laying out as the split it is.
- A map field stores coords as ['lat'=>…,'lng'=>…] and the view passed the array
  to urlencode(), killing the block.
- New: 'stacked' info cards, a footer spacer, heading rules and eyebrows on the
  contact block, and a nowrap on highlighted heading fragments so
  "Welcome to {OC-Sport}" cannot break mid-phrase.

Verified against a local Postgres replica carrying a real 11-page bilingual
site: 22 page/locale combinations answer 200 with zero logged block failures,
all 137 block/variant combinations render, reserved and unknown paths still 404,
the four migrations apply and roll back 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 0c9d0eec
......@@ -94,7 +94,11 @@ private function import(WebsiteBlueprintService $service): int
$stats = $service->import($blueprint, $actor, (bool) $this->option('replace'));
$this->info("Imported {$stats['pages']} page(s), {$stats['blocks']} block(s).");
$this->info("Imported {$stats['pages']} page(s), {$stats['blocks']} block(s), {$stats['menus']} menu item(s).");
$this->line($stats['settings']
? '<info>Theme settings applied.</info>'
: '<comment>No theme settings in this blueprint.</comment>');
if ($stats['skipped']) {
$this->warn('Skipped: '.implode(', ', array_unique($stats['skipped'])));
......
......@@ -33,6 +33,7 @@ public function variants(): array
{
return [
'cards' => 'بطاقات',
'stacked' => 'قائمة رأسية',
'inline' => 'صف واحد',
'bordered' => 'بإطار',
'icon_circle' => 'أيقونة دائرية',
......
......@@ -76,7 +76,12 @@ public function href(): ?string
return match ($this->link_type) {
'page' => $this->page?->url(),
'url' => safe_url($this->url),
'anchor' => $this->anchor ? '#'.ltrim($this->anchor, '#') : null,
// An anchor names a section of the homepage. From a sub-page a bare
// "#id" points at nothing, so the homepage is addressed explicitly
// — in the reader's current language.
'anchor' => $this->anchor
? website_url().'#'.ltrim($this->anchor, '#')
: null,
'route' => $this->route_name && \Illuminate\Support\Facades\Route::has($this->route_name)
? route($this->route_name)
: null,
......
......@@ -35,6 +35,7 @@ class WebsiteSetting extends Model
'animation_speed',
'navbar_style',
'navbar_cta_text',
'navbar_cta_text_en',
'navbar_cta_link',
'navbar_show_social',
'navbar_logo_position',
......
......@@ -8,7 +8,9 @@
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Blocks\FieldType;
use App\Domain\Website\Models\WebsiteBlock;
use App\Domain\Website\Models\WebsiteMenuItem;
use App\Domain\Website\Models\WebsitePage;
use App\Domain\Website\Models\WebsiteSetting;
use App\Models\User;
use Illuminate\Support\Facades\DB;
......@@ -27,13 +29,42 @@ public function __construct(
private readonly WebsitePageService $pages,
) {}
public const VERSION = 1;
public const VERSION = 2;
/** Blueprint versions this build can still read. */
public const SUPPORTED_VERSIONS = [1, 2];
/**
* Presentation settings a blueprint carries.
*
* Deliberately a whitelist. Tracking identifiers (analytics, pixel) are
* excluded even though they live on the same row: importing a design must
* never silently start reporting one client's traffic into another
* client's account.
*/
public const PORTABLE_SETTINGS = [
'template',
'primary_color', 'secondary_color', 'accent_color', 'text_color',
'background_color', 'surface_color', 'border_color', 'muted_text_color',
'heading_font', 'body_font', 'font_size_scale', 'heading_weight',
'line_height', 'letter_spacing', 'density', 'border_radius_preset',
'card_style', 'section_divider', 'image_shape',
'animations_enabled', 'animation_speed',
'navbar_style', 'navbar_cta_text', 'navbar_cta_text_en', 'navbar_cta_link', 'navbar_show_social',
'navbar_logo_position', 'navbar_secondary_logo_path', 'navbar_show_language',
'site_title', 'site_title_en', 'site_description', 'site_description_en',
'social_links', 'whatsapp_number', 'custom_css',
'footer_columns', 'footer_blocks', 'footer_bottom_text', 'footer_style',
'footer_powered_by', 'floating_elements', 'announcement_bar', 'popup_config',
];
/** @return array<string,mixed> */
public function export(): array
{
return [
'version' => self::VERSION,
'settings' => $this->exportSettings(),
'menus' => $this->exportMenus(),
'pages' => WebsitePage::with('blocks.childrenRecursive')
->orderBy('sort_order')
->get()
......@@ -52,6 +83,65 @@ public function export(): array
];
}
/** @return array<string,mixed> */
private function exportSettings(): array
{
$settings = WebsiteSetting::first();
if (! $settings) {
return [];
}
return collect(self::PORTABLE_SETTINGS)
->mapWithKeys(fn (string $key) => [$key => $settings->{$key}])
->reject(fn ($value) => $value === null)
->all();
}
/**
* Menus, with page links recorded by slug.
*
* A page id means nothing in another tenant's database, so an id-based
* export would import a navigation full of links to whatever happened to
* hold that id — or to nothing at all.
*
* @return array<string,mixed>
*/
private function exportMenus(): array
{
return collect(WebsiteMenuService::KEYS)
->mapWithKeys(fn (string $key) => [
$key => WebsiteMenuItem::query()
->whereHas('menu', fn ($q) => $q->where('key', $key))
->whereNull('parent_id')
->with(['childrenRecursive.page', 'page'])
->orderBy('sort_order')
->get()
->map(fn (WebsiteMenuItem $item) => $this->exportMenuItem($item))
->all(),
])
->reject(fn (array $items) => $items === [])
->all();
}
private function exportMenuItem(WebsiteMenuItem $item): array
{
return [
'label' => $item->label,
'label_en' => $item->label_en,
'link_type' => $item->link_type,
'page_slug' => $item->page?->slug,
'url' => $item->url,
'anchor' => $item->anchor,
'route_name' => $item->route_name,
'icon' => $item->icon,
'open_in_new_tab' => $item->open_in_new_tab,
'is_visible' => $item->is_visible,
'highlight' => $item->highlight,
'children' => $item->children->map(fn ($c) => $this->exportMenuItem($c))->all(),
];
}
private function exportBlock(WebsiteBlock $block): array
{
return [
......@@ -73,11 +163,11 @@ private function exportBlock(WebsiteBlock $block): array
*/
public function import(array $blueprint, User $actor, bool $replace = false): array
{
if (($blueprint['version'] ?? null) !== self::VERSION) {
if (! in_array($blueprint['version'] ?? null, self::SUPPORTED_VERSIONS, true)) {
throw new DomainException('إصدار ملف التصميم غير مدعوم.');
}
$stats = ['pages' => 0, 'blocks' => 0, 'skipped' => []];
$stats = ['pages' => 0, 'blocks' => 0, 'menus' => 0, 'settings' => false, 'skipped' => []];
DB::transaction(function () use ($blueprint, $actor, $replace, &$stats) {
foreach ($blueprint['pages'] ?? [] as $pageData) {
......@@ -121,11 +211,113 @@ public function import(array $blueprint, User $actor, bool $replace = false): ar
$stats['pages']++;
}
// Settings and menus land after the pages, because a menu item that
// points at a page can only be resolved once that page exists.
if (! empty($blueprint['settings'])) {
$this->importSettings($blueprint['settings']);
$stats['settings'] = true;
}
if (! empty($blueprint['menus'])) {
$stats['menus'] = $this->importMenus($blueprint['menus'], $replace);
}
});
return $stats;
}
/** Applies only the whitelisted presentation keys the blueprint carries. */
private function importSettings(array $settings): void
{
$academy = app('current_academy');
$row = WebsiteSetting::firstOrCreate(['academy_id' => $academy->id]);
$row->fill(collect($settings)
->only(self::PORTABLE_SETTINGS)
->all());
$row->save();
}
/**
* Rebuilds the named menus from the blueprint.
*
* With $replace the menu is emptied first; without it a menu that already
* has items is left alone, so an import cannot silently duplicate a
* navigation the client has since edited by hand.
*/
private function importMenus(array $menus, bool $replace): int
{
$service = app(WebsiteMenuService::class);
$pageIds = WebsitePage::pluck('id', 'slug');
$created = 0;
foreach ($menus as $key => $items) {
if (! in_array($key, WebsiteMenuService::KEYS, true) || ! is_array($items)) {
continue;
}
$menu = $service->getOrCreate($key);
$existing = WebsiteMenuItem::where('website_menu_id', $menu->id)->exists();
if ($existing && ! $replace) {
continue;
}
WebsiteMenuItem::where('website_menu_id', $menu->id)->delete();
$created += $this->importMenuItems($items, $menu->id, null, $pageIds);
}
return $created;
}
private function importMenuItems(array $items, int $menuId, ?int $parentId, $pageIds): int
{
$created = 0;
foreach (array_values($items) as $i => $data) {
if (! is_array($data)) {
continue;
}
$pageId = isset($data['page_slug']) ? ($pageIds[$data['page_slug']] ?? null) : null;
$linkType = $data['link_type'] ?? 'none';
// A page link whose target did not come with the blueprint would
// render as a dead entry; demote it to a plain label instead.
if ($linkType === 'page' && ! $pageId) {
$linkType = 'none';
}
$item = WebsiteMenuItem::create([
'website_menu_id' => $menuId,
'parent_id' => $parentId,
'label' => $data['label'] ?? null,
'label_en' => $data['label_en'] ?? null,
'link_type' => $linkType,
'website_page_id' => $linkType === 'page' ? $pageId : null,
'url' => $linkType === 'url' ? safe_url($data['url'] ?? null) : null,
'anchor' => $linkType === 'anchor' ? ($data['anchor'] ?? null) : null,
'route_name' => $linkType === 'route' ? ($data['route_name'] ?? null) : null,
'icon' => $data['icon'] ?? null,
'open_in_new_tab' => (bool) ($data['open_in_new_tab'] ?? false),
'is_visible' => (bool) ($data['is_visible'] ?? true),
'highlight' => (bool) ($data['highlight'] ?? false),
'sort_order' => $i,
]);
$created++;
if (! empty($data['children']) && is_array($data['children'])) {
$created += $this->importMenuItems($data['children'], $menuId, $item->id, $pageIds);
}
}
return $created;
}
/**
* Strips unsafe link targets from imported content.
*
......
......@@ -321,3 +321,29 @@ function website_highlight(?string $text): HtmlString
return new HtmlString($html ?? e($text));
}
}
if (! function_exists('website_text')) {
/**
* Resolves a possibly-bilingual value to the active locale.
*
* Block content stores translatable fields as ['ar' => …, 'en' => …].
* WebsiteBlock::get() unwraps that for top-level fields, but a repeater row
* read straight out of the data array does not go through it — so without
* this, a translatable sub-field reaches the template as an array and
* blows up the block at echo time.
*
* Falls back to the other locale rather than rendering an empty element,
* matching WebsiteBlock::get().
*/
function website_text(mixed $value, ?string $locale = null): mixed
{
if (! is_array($value) || ! array_key_exists('ar', $value)) {
return $value;
}
$primary = ($locale ?? app()->getLocale()) === 'ar' ? 'ar' : 'en';
$fallback = $primary === 'ar' ? 'en' : 'ar';
return filled($value[$primary] ?? null) ? $value[$primary] : ($value[$fallback] ?? null);
}
}
......@@ -4,6 +4,7 @@
use App\Domain\Shared\Models\Academy;
use App\Domain\Website\Models\WebsitePage;
use App\Domain\Website\Services\BlockRenderer;
use App\Domain\Website\Services\WebsitePageService;
use App\Domain\Website\Services\WebsiteSettingService;
use Illuminate\Http\Request;
......@@ -118,11 +119,32 @@ private function renderPage(Academy $academy, WebsitePage $page, bool $preview =
$page->load(['blocks.childrenRecursive']);
$settings = $this->settings->getOrCreate($academy);
/*
* Blocks are rendered to HTML here, before the layout runs — not from
* inside the layout's @section.
*
* BlockRenderer catches a failing block so the rest of the site stays
* up, but Laravel's View::render() calls flushState() whenever a view
* throws, and that clears the section stack of the page *around* it.
* Rendered inside @section('content'), one bad block therefore killed
* the whole page at @endsection with an unrelated error, defeating the
* very isolation the catch exists to provide. Out here there is no open
* section to corrupt.
*/
$content = app(BlockRenderer::class)->renderPage($page, [
'academy' => $academy,
'settings' => $settings,
'page' => $page,
]);
return view('website.page', [
'academy' => $academy,
'settings' => $this->settings->getOrCreate($academy),
'settings' => $settings,
'page' => $page,
'preview' => $preview,
'content' => $content,
]);
}
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Let `navbar_style` hold 'glass'.
*
* The frosted navbar template exists and the dispatcher accepts it, but the
* CHECK constraint predates it — so choosing it in the theme editor failed at
* write time with a constraint violation rather than a validation message.
*
* The three legacy values with no template of their own — hamburger_always,
* side_drawer, mega — are deliberately kept. Any of them already stored would
* make this migration fail if dropped, and the navbar dispatcher already falls
* back to 'solid' for a style it cannot resolve, so they are inert rather than
* broken.
*/
return new class extends Migration
{
private const STYLES = [
'solid', 'transparent', 'glass', 'floating', 'centered', 'minimal',
// Legacy, no template — the dispatcher renders these as 'solid'.
'hamburger_always', 'side_drawer', 'mega',
];
public function up(): void
{
if (! Schema::hasTable('website_settings') || ! Schema::hasColumn('website_settings', 'navbar_style')) {
return;
}
$list = collect(self::STYLES)->map(fn ($s) => "'".$s."'")->implode(', ');
DB::statement('ALTER TABLE website_settings DROP CONSTRAINT IF EXISTS website_settings_navbar_style_check');
DB::statement("ALTER TABLE website_settings ADD CONSTRAINT website_settings_navbar_style_check CHECK (navbar_style IN ({$list}))");
}
public function down(): void
{
if (! Schema::hasTable('website_settings') || ! Schema::hasColumn('website_settings', 'navbar_style')) {
return;
}
// Anything left on the value this migration introduced would violate the
// restored constraint, so move it to the nearest style that still exists.
DB::table('website_settings')->where('navbar_style', 'glass')->update(['navbar_style' => 'transparent']);
DB::statement('ALTER TABLE website_settings DROP CONSTRAINT IF EXISTS website_settings_navbar_style_check');
DB::statement("ALTER TABLE website_settings ADD CONSTRAINT website_settings_navbar_style_check CHECK (navbar_style IN ('solid', 'transparent', 'floating', 'centered', 'hamburger_always', 'side_drawer', 'minimal', 'mega'))");
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* An English label for the navbar call to action.
*
* Every other headline setting already comes in a pair (site_title /
* site_title_en), but the navbar's own button did not — so a bilingual site had
* to show one language's label to both audiences. Nullable, and the navbar
* falls back to the Arabic label, so nothing changes for a site that never
* fills it in.
*/
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_cta_text_en')) {
$table->string('navbar_cta_text_en', 100)->nullable()->after('navbar_cta_text');
}
});
}
public function down(): void
{
if (! Schema::hasTable('website_settings')) {
return;
}
Schema::table('website_settings', function (Blueprint $table) {
if (Schema::hasColumn('website_settings', 'navbar_cta_text_en')) {
$table->dropColumn('navbar_cta_text_en');
}
});
}
};
{
"+ فرعي": "+ Sub-item",
"أخرى": "Other",
"أدخل رابط الموقع أو الإحداثيات": "Enter a location URL or coordinates",
"أسبوع": "week",
"أصناف CSS إضافية": "Extra CSS classes",
"أو": "or",
"إخفاء": "Hide",
"إخفاء على الجوال": "Hide on mobile",
"إخفاء على الشاشات الكبيرة": "Hide on desktop",
"إرسال": "Send",
"إرسال رسالة أخرى": "Send another message",
"إضافة": "Add",
"إظهار": "Show",
"إظهار/إخفاء": "Show / hide",
"إغلاق": "Close",
"إلى": "to",
"ابحث بالاسم أو المنطقة": "Search by name or area",
"اترك هذا الحقل فارغًا": "Leave this field empty",
"احصل عليه من": "Get it on",
"اختر...": "Choose…",
"ارفع صورًا بالأسفل": "Upload images below",
"اشترك الآن": "Subscribe now",
"اقرأ الشروط": "Read the terms",
"اقرأ المزيد": "Read more",
"الأحد": "Sunday",
"الأربعاء": "Wednesday",
"الأيام": "Days",
"الإثنين": "Monday",
"الإيفنتات والبطولات": "Events & Tournaments",
"الاسم": "Name",
"البريد الإلكتروني": "Email",
"التاريخ": "Date",
"التسجيل في الإيفنت": "Register for the event",
"التسجيل مغلق حالياً": "Registration is currently closed",
"الثلاثاء": "Tuesday",
"الجمعة": "Friday",
"الخميس": "Thursday",
"الزاوية": "Angle",
"السبت": "Saturday",
"العودة للإيفنتات": "Back to events",
"الفئة": "Category",
"القائمة": "Menu",
"المجموعة": "Group",
"المسؤول": "Manager",
"المسافة الرأسية": "Vertical spacing",
"المقاعد": "Seats",
"المقاعد المتبقية": "Seats remaining",
"المكان": "Venue",
"الموضوع": "Subject",
"الموقع على الخريطة": "Location on the map",
"الوقت": "Time",
"بحث في الفروع": "Search branches",
"بدون": "None",
"بدون وجهة": "No target",
"تأثير العمق عند التمرير": "Parallax on scroll",
"تأخير البدء": "Start delay",
"تابع أحدث الفعاليات والبطولات وسجّل الآن": "Follow the latest events and tournaments, and register now",
"تتابع ظهور العناصر": "Stagger items",
"تدرج": "Gradient",
"تسجيل": "Register",
"تسجيل الدخول": "Login",
"تعتيم الطبقة": "Overlay opacity",
"تعديل": "Edit",
"تغيير اللغة": "Change language",
"تم استلام رسالتك بنجاح": "Your message has been received",
"تم اكتمال العدد": "Fully booked",
"تم التسجيل بنجاح!": "Registration complete",
"تُعطَّل كل الحركات تلقائيًا لمن يفضّل تقليل الحركة في إعدادات جهازه، وكذلك عند إيقاف الحركات من إعدادات الموقع.": "All motion is disabled automatically for readers who ask their device to reduce motion, and when animations are switched off in the site settings.",
"جارٍ الإرسال...": "Sending…",
"جارٍ التسجيل...": "Registering…",
"جارٍ...": "Working…",
"جميع الحقوق محفوظة.": "All rights reserved.",
"حدد...": "Select…",
"حذف": "Delete",
"حذف الصورة": "Remove image",
"حذف هذا العنصر؟": "Delete this item?",
"حركة الظهور": "Entrance animation",
"حركة لا تتوقف تُطبَّق على القسم بالكامل — استخدمها بحذر": "A continuous effect applied to the whole section — use sparingly",
"حركة مستمرة": "Continuous motion",
"حمّل من": "Download on",
"رابط التضمين غير صالح": "Invalid embed link",
"رابط الصورة أو ارفع ملفًا": "Image URL, or upload a file",
"رابط الفيديو": "Video URL",
"رسالتك": "Your Message",
"رفع": "Upload",
"رقم التسجيل الخاص بك:": "Your registration number:",
"رقم الهاتف": "Phone number",
"سجّل الآن": "Register now",
"سنة": "year",
"شعار": "Logo",
"صورة": "Image",
"عام": "year",
"عربي": "Arabic",
"عرض الأمام": "Show front",
"عرض الجدول": "View schedule",
"عرض الخلف": "Show back",
"عرض الصورة": "View image",
"عنصر هنا": "Item here",
"فريد": "Unique",
"فهرس الصفحة": "On this page",
"فيديو": "Video",
"قائمة منسدلة": "Dropdown",
"لأسفل": "Down",
"لأعلى": "Up",
"لا توجد إيفنتات حالياً": "No events at the moment",
"لا توجد بيانات لعرضها حاليًا": "Nothing to show yet",
"لا توجد صور بعد": "No images yet",
"لم يتم تحديد موقع": "No location set",
"لوحة التحكم": "Dashboard",
"لون": "Colour",
"لون الخلفية": "Background colour",
"لون النص": "Text colour",
"مدعوم بنظام El Captain": "Powered by El Captain",
"معاينة — هذه الصفحة غير منشورة للزوار": "Preview — this page is not published to visitors",
"معرض الصور": "Gallery",
"معرّف الرابط": "Anchor id",
"مفتوح": "Open",
"من": "from",
"نوع الخلفية": "Background type",
"يرجى الاحتفاظ بهذا الرقم للمتابعة": "Please keep this number for follow-up",
"يظهر أبناء القسم واحدًا تلو الآخر بدلًا من الظهور دفعة واحدة": "Reveals the section's children one after another instead of all at once",
"يفتح": "Opens",
"يفتح التسجيل:": "Registration opens:",
"يُستخدم للانتقال المباشر لهذا القسم": "Used to link directly to this section",
"— بدون —": "— None —"
}
\ No newline at end of file
......@@ -23,7 +23,9 @@
font-size: var(--site-text-base, 16px);
line-height: var(--site-line-height, 1.6);
letter-spacing: var(--site-letter-spacing, 0);
direction: rtl;
/* Direction comes from <html dir>, which follows the request locale.
Hardcoding rtl here silently overrode it and rendered the English
site right-to-left. */
min-height: 100vh;
overflow-x: hidden;
scroll-behavior: smooth;
......@@ -810,6 +812,10 @@
.ec-accent { color: var(--site-accent); }
.ec-accent-text { color: var(--site-accent); }
/* An emphasised fragment is one phrase; letting it break mid-word turns
"Welcome to {OC-Sport}" into "Welcome to OC-" / "Sport". */
.ec-heading .ec-accent-text { white-space: nowrap; }
/* An eyebrow labels the section above its heading: small, spaced, accent. */
.ec-eyebrow {
color: var(--site-accent);
......
......@@ -6,7 +6,7 @@
@continue (! $btnHref)
<a href="{{ $btnHref }}"
class="ec-btn ec-btn--outline inline-flex items-center rounded-full px-7 py-3 font-semibold transition">
{{ $block->get("buttons.{$i}.label") ?: data_get($btn, 'label') }}
{{ $block->get("buttons.{$i}.label") ?: website_text(data_get($btn, 'label')) }}
</a>
@endforeach
</div>
......
......@@ -21,8 +21,8 @@
<div x-data="{ open: null }" class="{{ $variant === 'two_column' ? 'md:columns-2 md:gap-8' : '' }}">
@foreach ($items as $i => $item)
@php
$q = $block->get("items.{$i}.question") ?: (data_get($item, 'question') ?: data_get($item, 'question_ar'));
$a = $block->get("items.{$i}.answer") ?: (data_get($item, 'answer') ?: data_get($item, 'answer_ar'));
$q = $block->get("items.{$i}.question") ?: (website_text(data_get($item, 'question')) ?: data_get($item, 'question_ar'));
$a = $block->get("items.{$i}.answer") ?: (website_text(data_get($item, 'answer')) ?: data_get($item, 'answer_ar'));
@endphp
<div class="{{ $itemClass }} break-inside-avoid overflow-hidden">
<h3>
......
......@@ -24,9 +24,9 @@
<li class="flex items-start gap-3">
<x-website.icon :name="data_get($f, 'icon', 'check')" class="w-5 h-5 mt-0.5 ec-accent shrink-0" />
<div class="min-w-0 text-start">
<p class="font-semibold">{{ $block->get("features.{$i}.title") ?: data_get($f, 'title') }}</p>
@if (data_get($f, 'body'))
<p class="ec-muted text-sm">{{ $block->get("features.{$i}.body") ?: data_get($f, 'body') }}</p>
<p class="font-semibold">{{ $block->get("features.{$i}.title") ?: website_text(data_get($f, 'title')) }}</p>
@if (website_text(data_get($f, 'body')))
<p class="ec-muted text-sm">{{ $block->get("features.{$i}.body") ?: website_text(data_get($f, 'body')) }}</p>
@endif
</div>
</li>
......
......@@ -39,11 +39,11 @@ class="ec-stagger-item {{ $cardClass }} flex {{ $iconInline ? 'flex-row gap-4' :
@endif
<div class="flex flex-col gap-2 min-w-0">
@if (data_get($item, 'title'))
<h3 class="ec-heading text-xl font-semibold">{{ $block->get("items.{$i}.title") ?: data_get($item, 'title') }}</h3>
@if (website_text(data_get($item, 'title')))
<h3 class="ec-heading text-xl font-semibold">{{ $block->get("items.{$i}.title") ?: website_text(data_get($item, 'title')) }}</h3>
@endif
@if (data_get($item, 'body'))
<div class="ec-muted leading-relaxed">{!! clean_html($block->get("items.{$i}.body") ?: data_get($item, 'body')) !!}</div>
@if (website_text(data_get($item, 'body')))
<div class="ec-muted leading-relaxed">{!! clean_html($block->get("items.{$i}.body") ?: website_text(data_get($item, 'body'))) !!}</div>
@endif
</div>
</{{ $tag }}>
......
......@@ -2,8 +2,19 @@
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="{{ $split ? 'grid md:grid-cols-2 gap-12' : 'max-w-2xl mx-auto' }}">
<div class="flex flex-col gap-4 {{ $split ? '' : 'text-center mb-8' }}">
@if ($block->get('eyebrow'))
<p class="ec-eyebrow">{{ $block->get('eyebrow') }}</p>
@endif
@if ($block->get('title'))
<h2 class="ec-heading text-3xl sm:text-4xl font-bold">{{ $block->get('title') }}</h2>
@php
$ruleAlign = ($block->get('header_align') ?? ($split ? 'start' : 'center')) === 'center' ? 'center' : 'start';
$ruleClass = $block->get('heading_rule') === 'accent_bar'
? 'ec-heading-rule ec-heading-rule--'.$ruleAlign
: '';
@endphp
<h2 class="ec-heading text-3xl sm:text-4xl font-black {{ $ruleClass }}">
{!! website_highlight($block->get('title')) !!}
</h2>
@endif
@if ($block->get('description'))
<p class="ec-muted text-lg leading-relaxed">{{ $block->get('description') }}</p>
......
......@@ -34,7 +34,7 @@ class="ec-btn ec-btn--{{ data_get($btn, 'style', 'primary') }} inline-flex items
@if (data_get($btn, 'icon'))
<x-website.icon :name="data_get($btn, 'icon')" class="w-5 h-5" />
@endif
{{ $block->get("buttons.{$i}.label") ?: data_get($btn, 'label') }}
{{ $block->get("buttons.{$i}.label") ?: website_text(data_get($btn, 'label')) }}
</a>
@endforeach
</div>
......
......@@ -8,7 +8,9 @@
$buttons = collect($block->get('buttons') ?: []);
$products = collect($block->get('products') ?: []);
$slides = collect($block->get('slides') ?: []);
$split = in_array($variant, ['split_start', 'split_end'], true);
// product_showcase is a split layout too: copy on one side, the showcase on
// the other. Without this the showcase stacked under a centred headline.
$split = in_array($variant, ['split_start', 'split_end', 'product_showcase'], true);
// Ambient motion for the showcase image only — the section around it stays put.
$showcaseMotion = trim([
......@@ -66,7 +68,7 @@ class="ec-btn ec-btn--{{ data_get($btn, 'style', 'primary') }} inline-flex items
@if (data_get($btn, 'icon'))
<x-website.icon :name="data_get($btn, 'icon')" class="w-5 h-5" />
@endif
{{ $block->get("buttons.{$loop->index}.label") ?: data_get($btn, 'label') }}
{{ $block->get("buttons.{$loop->index}.label") ?: website_text(data_get($btn, 'label')) }}
</a>
@endforeach
</div>
......@@ -80,7 +82,7 @@ class="ec-btn ec-btn--{{ data_get($btn, 'style', 'primary') }} inline-flex items
@foreach (['front', 'back'] as $face)
@if (data_get($product, $face))
<img src="{{ data_get($product, $face) }}"
alt="{{ data_get($product, 'name') }}"
alt="{{ website_text(data_get($product, 'name')) }}"
class="absolute inset-0 h-full w-full object-contain transition-all duration-500"
x-bind:class="(active === {{ $idx }} && back === {{ $face === 'back' ? 'true' : 'false' }}) ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'">
@endif
......@@ -93,7 +95,7 @@ class="absolute inset-0 h-full w-full object-contain transition-all duration-500
class="w-8 h-8 rounded-full border-2 transition"
x-bind:class="active === {{ $idx }} ? 'ring-2 ring-offset-2 scale-110' : 'opacity-70'"
style="background:{{ data_get($product, 'swatch', '#ccc') }}"
aria-label="{{ data_get($product, 'name') }}"></button>
aria-label="{{ website_text(data_get($product, 'name')) }}"></button>
@endforeach
</div>
<button type="button" x-on:click="back = !back" class="ec-muted text-sm underline">
......
@php
$href = function ($item) {
$v = data_get($item, 'action_value') ?: data_get($item, 'value');
$v = website_text(data_get($item, 'action_value')) ?: website_text(data_get($item, 'value'));
return match (data_get($item, 'action', 'none')) {
'tel' => 'tel:' . preg_replace('/\s+/', '', $v),
'mailto' => 'mailto:' . $v,
......@@ -10,10 +10,15 @@
default => null,
};
};
// A stacked list reads as contact details rather than as a set of cards:
// one row per channel, aligned to the leading edge.
$stacked = $variant === 'stacked';
$card = match ($variant) {
'bordered' => 'border rounded-2xl p-6',
'icon_circle' => 'flex-col items-center text-center gap-3 p-6',
'inline' => 'flex-row items-center gap-3',
'stacked' => 'flex-row items-center gap-4',
default => 'ec-surface rounded-2xl p-6 shadow-sm',
};
@endphp
......@@ -21,20 +26,22 @@
@if ($block->get('title'))
<h2 class="ec-heading text-2xl sm:text-3xl font-bold text-center mb-8">{{ $block->get('title') }}</h2>
@endif
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
<div class="{{ $stacked ? 'flex flex-col gap-6' : 'grid gap-5 sm:grid-cols-2 lg:grid-cols-3' }}">
@foreach ($items as $i => $item)
@php $link = $href($item); @endphp
<{{ $link ? 'a' : 'div' }} @if ($link) href="{{ $link }}" @if (in_array(data_get($item, 'action'), ['whatsapp', 'maps', 'url'])) target="_blank" rel="noopener noreferrer" @endif @endif
class="ec-stagger-item flex {{ $card }} gap-4 {{ $link ? 'transition hover:-translate-y-0.5' : '' }}">
@if (data_get($item, 'icon'))
<span class="ec-accent inline-flex items-center justify-center w-12 h-12 rounded-full border shrink-0">
<span class="ec-accent inline-flex items-center justify-center w-12 h-12 shrink-0
{{ $stacked ? 'rounded-xl border-0' : 'rounded-full border' }}"
@if ($stacked) style="background: color-mix(in srgb, var(--site-accent) 15%, transparent)" @endif>
<x-website.icon :name="data_get($item, 'icon')" class="w-5 h-5" />
</span>
@endif
<div class="min-w-0">
<p class="ec-muted text-sm">{{ $block->get("items.{$i}.label") ?: data_get($item, 'label') }}</p>
<p class="ec-muted {{ $stacked ? 'text-xs font-bold uppercase tracking-widest' : 'text-sm' }}">{{ $block->get("items.{$i}.label") ?: website_text(data_get($item, 'label')) }}</p>
<p class="font-semibold break-words" @if (in_array(data_get($item, 'action'), ['tel', 'whatsapp'])) dir="ltr" @endif>
{{ $block->get("items.{$i}.value") ?: data_get($item, 'value') }}
{{ $block->get("items.{$i}.value") ?: website_text(data_get($item, 'value')) }}
</p>
</div>
</{{ $link ? 'a' : 'div' }}>
......
......@@ -3,7 +3,7 @@
$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')) : '';
$name = fn ($p) => is_array($p) ? (website_text(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">
......
......@@ -4,7 +4,18 @@
? $items->map(fn ($b) => ['name' => $b->name_ar ?? $b->name ?? '', 'address' => $b->address ?? '', 'coords' => $b->coordinates ?? null])
: collect($block->get('locations') ?: []);
$primary = $locations->first();
$query = $primary ? (data_get($primary, 'coords') ?: data_get($primary, 'address')) : null;
// A map field stores coordinates as ['lat' => …, 'lng' => …]; Google's embed
// wants "lat,lng". Passing the array straight through broke the whole block.
$coords = data_get($primary, 'coords');
if (is_array($coords)) {
$lat = data_get($coords, 'lat');
$lng = data_get($coords, 'lng');
$coords = (filled($lat) && filled($lng)) ? $lat.','.$lng : null;
}
$query = $primary ? ($coords ?: website_text(data_get($primary, 'address'))) : null;
$query = is_string($query) ? $query : null;
@endphp
<div class="{{ $variant === 'full_width' ? '' : 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8' }}">
@if ($block->get('title'))
......@@ -15,8 +26,8 @@
<ul class="flex flex-col gap-3">
@foreach ($locations as $loc)
<li class="ec-surface rounded-xl p-4">
<p class="font-semibold">{{ data_get($loc, 'name') }}</p>
<p class="ec-muted text-sm">{{ data_get($loc, 'address') }}</p>
<p class="font-semibold">{{ website_text(data_get($loc, 'name')) }}</p>
<p class="ec-muted text-sm">{{ website_text(data_get($loc, 'address')) }}</p>
</li>
@endforeach
</ul>
......
......@@ -18,20 +18,20 @@
@foreach ($plans as $i => $plan)
@php $featured = (bool) data_get($plan, 'featured'); @endphp
<div class="ec-stagger-item relative flex flex-col gap-5 rounded-2xl p-8 {{ $featured ? 'ec-surface ring-2 shadow-lg lg:-translate-y-3' : 'border' }}">
@if (data_get($plan, 'badge'))
<span class="absolute -top-3 start-8 ec-accent text-xs font-bold px-3 py-1 rounded-full border bg-white">{{ $block->get("plans.{$i}.badge") ?: data_get($plan, 'badge') }}</span>
@if (website_text(data_get($plan, 'badge')))
<span class="absolute -top-3 start-8 ec-accent text-xs font-bold px-3 py-1 rounded-full border bg-white">{{ $block->get("plans.{$i}.badge") ?: website_text(data_get($plan, 'badge')) }}</span>
@endif
<div>
<h3 class="ec-heading text-xl font-bold">{{ $block->get("plans.{$i}.name") ?: data_get($plan, 'name') }}</h3>
@if (data_get($plan, 'description'))
<p class="ec-muted text-sm mt-2">{{ $block->get("plans.{$i}.description") ?: data_get($plan, 'description') }}</p>
<h3 class="ec-heading text-xl font-bold">{{ $block->get("plans.{$i}.name") ?: website_text(data_get($plan, 'name')) }}</h3>
@if (website_text(data_get($plan, 'description')))
<p class="ec-muted text-sm mt-2">{{ $block->get("plans.{$i}.description") ?: website_text(data_get($plan, 'description')) }}</p>
@endif
</div>
<p class="flex items-baseline gap-2">
<span class="ec-heading text-4xl font-extrabold" dir="ltr">{{ data_get($plan, 'price') }}</span>
<span class="ec-muted text-sm">{{ $currency }}{{ data_get($plan, 'period') ? ' / ' . (($block->get("plans.{$i}.period")) ?: data_get($plan, 'period')) : '' }}</span>
<span class="ec-heading text-4xl font-extrabold" dir="ltr">{{ website_text(data_get($plan, 'price')) }}</span>
<span class="ec-muted text-sm">{{ $currency }}{{ website_text(data_get($plan, 'period')) ? ' / ' . (($block->get("plans.{$i}.period")) ?: website_text(data_get($plan, 'period'))) : '' }}</span>
</p>
@if ($features = collect(data_get($plan, 'features') ?: []))
......@@ -40,7 +40,7 @@
@php $inc = data_get($f, 'included', true); @endphp
<li class="flex items-start gap-2.5 {{ $inc ? '' : 'opacity-45 line-through' }}">
<x-website.icon :name="$inc ? 'check' : 'x-mark'" class="w-4 h-4 mt-1 shrink-0 ec-accent" />
<span class="text-sm">{{ data_get($f, 'text') }}</span>
<span class="text-sm">{{ website_text(data_get($f, 'text')) }}</span>
</li>
@endforeach
</ul>
......@@ -50,7 +50,7 @@
@if ($planHref)
<a href="{{ $planHref }}"
class="ec-btn ec-btn--{{ $featured ? 'primary' : 'outline' }} mt-auto text-center rounded-full px-6 py-3 font-semibold transition">
{{ $block->get("plans.{$i}.button_label") ?: data_get($plan, 'button_label') ?: __('اشترك الآن') }}
{{ $block->get("plans.{$i}.button_label") ?: website_text(data_get($plan, 'button_label')) ?: __('اشترك الآن') }}
</a>
@endif
</div>
......
......@@ -87,8 +87,8 @@ class="w-full {{ $centered ? 'aspect-square rounded-full' : 'aspect-[4/5] rounde
<x-website.icon :name="data_get($d, 'icon')" class="w-5 h-5 mt-0.5 ec-accent shrink-0" />
@endif
<div class="min-w-0">
<dt class="ec-muted text-xs uppercase tracking-wide">{{ $block->get("details.{$i}.label") ?: data_get($d, 'label') }}</dt>
<dd class="font-medium">{{ $block->get("details.{$i}.value") ?: data_get($d, 'value') }}</dd>
<dt class="ec-muted text-xs uppercase tracking-wide">{{ $block->get("details.{$i}.label") ?: website_text(data_get($d, 'label')) }}</dt>
<dd class="font-medium">{{ $block->get("details.{$i}.value") ?: website_text(data_get($d, 'value')) }}</dd>
</div>
</div>
@endforeach
......@@ -108,7 +108,7 @@ class="w-full {{ $centered ? 'aspect-square rounded-full' : 'aspect-[4/5] rounde
@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>
<span class="ec-muted leading-relaxed">{{ $block->get("achievements.{$i}.text") ?: website_text(data_get($a, 'text')) }}</span>
</li>
@endforeach
</ul>
......
......@@ -17,8 +17,8 @@
<nav class="hidden lg:block self-start sticky top-24" aria-label="{{ __('فهرس الصفحة') }}">
<ul class="flex flex-col gap-2 text-sm">
@foreach ($sections as $i => $s)
@php $anchor = data_get($s, 'anchor') ?: 'sec-' . ($i + 1); @endphp
<li><a href="#{{ $anchor }}" class="ec-muted hover:underline">{{ $block->get("sections.{$i}.heading") ?: data_get($s, 'heading') }}</a></li>
@php $anchor = website_text(data_get($s, 'anchor')) ?: 'sec-' . ($i + 1); @endphp
<li><a href="#{{ $anchor }}" class="ec-muted hover:underline">{{ $block->get("sections.{$i}.heading") ?: website_text(data_get($s, 'heading')) }}</a></li>
@endforeach
</ul>
</nav>
......@@ -30,12 +30,12 @@
@endif
@foreach ($sections as $i => $s)
@php $anchor = data_get($s, 'anchor') ?: 'sec-' . ($i + 1); @endphp
@php $anchor = website_text(data_get($s, 'anchor')) ?: 'sec-' . ($i + 1); @endphp
<section id="{{ $anchor }}" class="mt-10 break-inside-avoid scroll-mt-24">
@if (data_get($s, 'heading'))
<h2 class="ec-heading text-xl font-semibold mb-3">{{ $block->get("sections.{$i}.heading") ?: data_get($s, 'heading') }}</h2>
@if (website_text(data_get($s, 'heading')))
<h2 class="ec-heading text-xl font-semibold mb-3">{{ $block->get("sections.{$i}.heading") ?: website_text(data_get($s, 'heading')) }}</h2>
@endif
{!! clean_html($block->get("sections.{$i}.body") ?: data_get($s, 'body')) !!}
{!! clean_html($block->get("sections.{$i}.body") ?: website_text(data_get($s, 'body'))) !!}
</section>
@endforeach
</div>
......
......@@ -14,11 +14,11 @@
@php $linkHref = safe_url(data_get($link, 'url')); @endphp
@continue (! $linkHref)
<a href="{{ $linkHref }}" target="_blank" rel="noopener noreferrer"
aria-label="{{ data_get($link, 'label') ?: data_get($link, 'platform') }}"
aria-label="{{ website_text(data_get($link, 'label')) ?: data_get($link, 'platform') }}"
class="inline-flex items-center justify-center {{ $variant === 'icons' ? $size . ' rounded-full border' : 'gap-2 rounded-full border px-5 py-2.5' }} transition hover:-translate-y-0.5">
<x-website.icon :name="data_get($link, 'platform')" :class="$icon" />
@if ($variant !== 'icons')
<span class="font-medium">{{ data_get($link, 'label') ?: data_get($link, 'platform') }}</span>
<span class="font-medium">{{ website_text(data_get($link, 'label')) ?: data_get($link, 'platform') }}</span>
@endif
</a>
@endforeach
......
......@@ -17,9 +17,9 @@
<x-website.icon :name="data_get($item, 'icon')" class="w-8 h-8 ec-accent mb-1" />
@endif
<div class="ec-heading font-extrabold {{ $variant === 'big_numbers' ? 'text-5xl sm:text-6xl' : 'text-4xl' }}" dir="ltr">
<span @if ($animate) data-counter="{{ preg_replace('/\D/', '', (string) data_get($item, 'value')) }}" @endif>{{ data_get($item, 'value') }}</span><span class="ec-accent">{{ data_get($item, 'suffix') }}</span>
<span @if ($animate) data-counter="{{ preg_replace('/\D/', '', (string) website_text(data_get($item, 'value'))) }}" @endif>{{ website_text(data_get($item, 'value')) }}</span><span class="ec-accent">{{ website_text(data_get($item, 'suffix')) }}</span>
</div>
<p class="ec-muted text-sm sm:text-base">{{ $block->get("items.{$i}.label") ?: data_get($item, 'label') }}</p>
<p class="ec-muted text-sm sm:text-base">{{ $block->get("items.{$i}.label") ?: website_text(data_get($item, 'label')) }}</p>
</div>
@endforeach
</div>
......
......@@ -43,7 +43,7 @@
@foreach ($bullets as $i => $bullet)
<li class="flex items-start gap-3">
<x-website.icon :name="data_get($bullet, 'icon', 'check')" class="w-5 h-5 mt-1 shrink-0 ec-accent" />
<span>{{ $block->get("bullets.{$i}.text") ?: data_get($bullet, 'text') }}</span>
<span>{{ $block->get("bullets.{$i}.text") ?: website_text(data_get($bullet, 'text')) }}</span>
</li>
@endforeach
</ul>
......@@ -55,7 +55,7 @@
@continue (! $btnHref)
<a href="{{ $btnHref }}"
class="ec-btn ec-btn--{{ data_get($btn, 'style', 'primary') }} inline-flex items-center rounded-full px-6 py-3 font-semibold transition">
{{ $block->get("buttons.{$i}.label") ?: data_get($btn, 'label') }}
{{ $block->get("buttons.{$i}.label") ?: website_text(data_get($btn, 'label')) }}
</a>
@endforeach
</div>
......
......@@ -13,6 +13,10 @@
$siteBlurb = $isAr
? ($settings->site_description ?: $settings->site_description_en)
: ($settings->site_description_en ?: $settings->site_description);
// A footer column heading is authored content, so it may be stored either as
// a plain string or as an ['ar' => …, 'en' => …] pair.
$blockTitle = fn ($block, $fallback = '') => website_text($block['title'] ?? null) ?: $fallback;
$gridClass = match($columns) {
2 => 'grid-cols-1 md:grid-cols-2',
3 => 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
......@@ -27,21 +31,46 @@
<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="{{ $academyName }}" class="h-12 w-12 rounded-lg object-contain">
<img src="{{ asset('storage/' . $academy->logo_path) }}" alt="{{ $academyName }}" class="h-14 w-auto max-w-[10rem] object-contain">
@endif
@if($settings->navbar_secondary_logo_path)
<span class="h-9 w-px bg-current opacity-25" aria-hidden="true"></span>
<img src="{{ asset('storage/' . $settings->navbar_secondary_logo_path) }}" alt="" aria-hidden="true" class="h-12 w-auto max-w-[10rem] object-contain">
@elseif(! $academy->logo_path)
<span class="text-xl font-bold {{ $textColor }}">{{ $academyName }}</span>
@endif
<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>
<p class="{{ $mutedColor }} leading-relaxed max-w-md">{{ website_text($block['content']) }}</p>
@elseif($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-4 mt-6">
@foreach(['facebook', 'instagram', 'youtube', 'tiktok', 'twitter'] as $platform)
@if(!empty($settings->social_links[$platform]))
<a href="{{ safe_url($settings->social_links[$platform]) ?: url('/') }}" target="_blank" rel="noopener noreferrer"
class="{{ $textColor }} transition hover:text-[var(--site-accent)]"
aria-label="{{ $platform }}">
@include("website.icons.{$platform}")
</a>
@endif
@endforeach
@if($settings->whatsapp_number)
<a href="{{ whatsapp_link($settings->whatsapp_number) }}" target="_blank" rel="noopener noreferrer"
class="{{ $textColor }} transition hover:text-[var(--site-accent)]" aria-label="whatsapp">
<x-website.icon name="whatsapp" class="w-5 h-5" />
</a>
@endif
</div>
@endif
</div>
@break
@case('links')
<div>
<h4 class="{{ $textColor }} font-bold mb-4">{{ $block['title'] ?? 'روابط سريعة' }}</h4>
<h4 class="{{ $textColor }} font-bold mb-4">{{ $blockTitle($block, 'روابط سريعة') }}</h4>
<ul class="space-y-2">
@if(!empty($block['links']))
@foreach($block['links'] as $link)
......@@ -64,11 +93,16 @@
</div>
@break
@case('spacer')
{{-- Holds a grid cell so the columns below start where intended. --}}
<div aria-hidden="true"></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>
<h4 class="{{ $textColor }} font-bold mb-4">{{ $blockTitle($block, '') }}</h4>
@include('website.partials.site-menu', [
'menuKey' => $block['menu_key'] ?? 'footer',
'layout' => 'stacked',
......@@ -79,7 +113,7 @@
@case('contact')
<div>
<h4 class="{{ $textColor }} font-bold mb-4">{{ $block['title'] ?? 'تواصل معنا' }}</h4>
<h4 class="{{ $textColor }} font-bold mb-4">{{ $blockTitle($block, 'تواصل معنا') }}</h4>
<ul class="space-y-3 text-sm">
@if($academy->phone)
<li class="flex items-center gap-2 {{ $mutedColor }}">
......@@ -105,7 +139,7 @@
@case('social')
<div>
<h4 class="{{ $textColor }} font-bold mb-4">{{ $block['title'] ?? 'تابعنا' }}</h4>
<h4 class="{{ $textColor }} font-bold mb-4">{{ $blockTitle($block, 'تابعنا') }}</h4>
@if(is_array($settings->social_links ?? null) && count(array_filter($settings->social_links)))
<div class="flex items-center gap-3 flex-wrap">
@foreach(['facebook', 'instagram', 'twitter', 'youtube', 'tiktok'] as $platform)
......@@ -124,7 +158,7 @@ class="w-10 h-10 rounded-lg bg-white/5 border {{ $borderColor }} flex items-cent
@case('hours')
<div>
<h4 class="{{ $textColor }} font-bold mb-4">{{ $block['title'] ?? 'ساعات العمل' }}</h4>
<h4 class="{{ $textColor }} font-bold mb-4">{{ $blockTitle($block, 'ساعات العمل') }}</h4>
@if(!empty($block['hours']))
<ul class="space-y-2 text-sm">
@foreach($block['hours'] as $hour)
......
......@@ -14,7 +14,12 @@
$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 ?: __('سجّل الآن');
// null means "never configured" and keeps the default label; an explicitly
// empty value means the site chose to have no call to action at all.
$ctaBase = app()->getLocale() === 'ar'
? ($settings->navbar_cta_text ?? null)
: ($settings->navbar_cta_text_en ?: ($settings->navbar_cta_text ?? null));
$ctaText = $settings->navbar_cta_text === null ? __('سجّل الآن') : (string) $ctaBase;
@endphp
<div class="flex items-center {{ $compact ? 'gap-1.5' : 'gap-2 sm:gap-3' }}">
......
......@@ -7,9 +7,6 @@
</div>
@endif
{!! app(\App\Domain\Website\Services\BlockRenderer::class)->renderPage($page, [
'academy' => $academy,
'settings' => $settings,
'page' => $page,
]) !!}
{{-- Already rendered by the controller; see WebsitePageController::renderPage(). --}}
{!! $content !!}
@endsection
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