Commit 39f468a9 authored by Claude's avatar Claude

feat(website): add page + block tree builder (v3)

The v2 builder could not express more than one page: website_sections had a
unique(academy_id, section_key) constraint, there was no pages table, and
SectionManager exposed only toggle + reorder. A client with a seven-page site
had no way to represent page two.

Adds an additive page/block model alongside v2:

- website_pages + website_blocks (nested tree, JSONB data/style)
- BlockRegistry of BlockType classes: 31 types, 133 layout variants, 237
  fields, 442 validation rules derived from the field schema
- Page/Block/Menu/Blueprint services, BlockRenderer, BlockDataResolver
- Builder UI: page manager, block tree editor, schema-driven field forms,
  repeaters, content/design/motion panels, image upload
- Authored navigation (website_menus) with dropdowns, replacing nav links
  that were previously derived from enabled sections
- Blueprint import/export via `php artisan website:blueprint`
- Extended motion library: entrance effects, delay, stagger, parallax

A new block type now costs one PHP class — no migration, no enum case, no
CHECK constraint.

Nothing here is destructive. website_sections is untouched and "/" falls back
to the legacy renderer when no builder homepage exists, so already-deployed
tenants are unaffected until they opt in.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent c54f5aea
...@@ -56,3 +56,6 @@ CLAUDE.md ...@@ -56,3 +56,6 @@ CLAUDE.md
"Beanding Guide.txt" "Beanding Guide.txt"
"system info.txt" "system info.txt"
elcaptain-sportsonly-db.md elcaptain-sportsonly-db.md
# Client site mirrors / migration reference (kept in git, never shipped)
reference/
<?php
namespace App\Console\Commands;
use App\Domain\Shared\Models\Academy;
use App\Domain\Website\Services\WebsiteBlueprintService;
use App\Models\User;
use Illuminate\Console\Command;
/**
* Export or import a whole website design as JSON.
*
* Deliberately an explicit command, never part of db:seed — every tenant boots
* from the same image and runs seeders on start, so an automatic import would
* push one client's site into every other client's instance.
*/
class WebsiteBlueprintCommand extends Command
{
protected $signature = 'website:blueprint
{action : export|import}
{--file= : Path to the blueprint JSON}
{--academy= : Academy id (defaults to the first academy)}
{--replace : Overwrite pages whose slug already exists}';
protected $description = 'Export or import a website design (pages + blocks) as a JSON blueprint';
public function handle(WebsiteBlueprintService $service): int
{
$academy = $this->option('academy')
? Academy::find($this->option('academy'))
: Academy::first();
if (! $academy) {
$this->error('No academy found.');
return self::FAILURE;
}
app()->instance('current_academy', $academy);
$this->line("Academy: <info>{$academy->id}</info> — ".($academy->name_ar ?? $academy->name ?? ''));
return match ($this->argument('action')) {
'export' => $this->export($service),
'import' => $this->import($service),
default => tap(self::FAILURE, fn () => $this->error('Action must be export or import.')),
};
}
private function export(WebsiteBlueprintService $service): int
{
$file = $this->option('file') ?: storage_path('app/website-blueprint.json');
$blueprint = $service->export();
@mkdir(dirname($file), 0775, true);
file_put_contents(
$file,
json_encode($blueprint, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
);
$this->info(sprintf('Exported %d page(s) to %s', count($blueprint['pages']), $file));
return self::SUCCESS;
}
private function import(WebsiteBlueprintService $service): int
{
$file = $this->option('file');
if (! $file || ! is_file($file)) {
$this->error('Provide an existing --file to import.');
return self::FAILURE;
}
$blueprint = json_decode((string) file_get_contents($file), true);
if (! is_array($blueprint)) {
$this->error('Blueprint is not valid JSON.');
return self::FAILURE;
}
$actor = User::query()->orderBy('id')->first();
if (! $actor) {
$this->error('No user available to attribute the import to.');
return self::FAILURE;
}
if (! $this->option('replace') && ! $this->confirm('Import without replacing existing pages of the same slug?', true)) {
return self::SUCCESS;
}
$stats = $service->import($blueprint, $actor, (bool) $this->option('replace'));
$this->info("Imported {$stats['pages']} page(s), {$stats['blocks']} block(s).");
if ($stats['skipped']) {
$this->warn('Skipped: '.implode(', ', array_unique($stats['skipped'])));
}
return self::SUCCESS;
}
}
<?php
namespace App\Domain\Website\Blocks;
/**
* Declarative description of one editable field on a block type.
*
* Field definitions are the contract between a block type and the builder UI:
* the UI renders inputs from these, and validation rules are derived from these,
* so a new block type needs no bespoke form and no migration.
*/
class BlockField
{
public bool $translatable = false;
public bool $required = false;
public mixed $default = null;
public ?string $help = null;
public array $options = [];
/** @var BlockField[] Sub-fields, for Repeater fields only. */
public array $fields = [];
public ?string $showIf = null;
public mixed $showIfValue = null;
public ?int $max = null;
public ?int $min = null;
public ?string $placeholder = null;
final public function __construct(
public readonly string $key,
public readonly string $label,
public readonly FieldType $type,
) {
$this->translatable = $type->isTranslatable();
}
public static function make(string $key, string $label, FieldType $type): static
{
return new static($key, $label, $type);
}
public static function text(string $key, string $label): static
{
return static::make($key, $label, FieldType::Text);
}
public static function textarea(string $key, string $label): static
{
return static::make($key, $label, FieldType::Textarea);
}
public static function richText(string $key, string $label): static
{
return static::make($key, $label, FieldType::RichText);
}
public static function image(string $key, string $label): static
{
return static::make($key, $label, FieldType::Image);
}
public static function gallery(string $key, string $label): static
{
return static::make($key, $label, FieldType::Gallery);
}
public static function video(string $key, string $label): static
{
return static::make($key, $label, FieldType::Video);
}
public static function icon(string $key, string $label): static
{
return static::make($key, $label, FieldType::Icon);
}
public static function link(string $key, string $label): static
{
return static::make($key, $label, FieldType::Link);
}
public static function color(string $key, string $label): static
{
return static::make($key, $label, FieldType::Color);
}
public static function toggle(string $key, string $label): static
{
return static::make($key, $label, FieldType::Toggle)->default(false);
}
public static function number(string $key, string $label): static
{
return static::make($key, $label, FieldType::Number);
}
public static function date(string $key, string $label): static
{
return static::make($key, $label, FieldType::Date);
}
public static function code(string $key, string $label): static
{
return static::make($key, $label, FieldType::Code);
}
public static function map(string $key, string $label): static
{
return static::make($key, $label, FieldType::Map);
}
public static function alignment(string $key = 'align', string $label = 'المحاذاة'): static
{
return static::make($key, $label, FieldType::Alignment)
->options(['start' => 'البداية', 'center' => 'الوسط', 'end' => 'النهاية'])
->default('center');
}
public static function select(string $key, string $label, array $options): static
{
return static::make($key, $label, FieldType::Select)->options($options);
}
public static function radio(string $key, string $label, array $options): static
{
return static::make($key, $label, FieldType::Radio)->options($options);
}
/** @param BlockField[] $fields */
public static function repeater(string $key, string $label, array $fields): static
{
$f = static::make($key, $label, FieldType::Repeater);
$f->fields = $fields;
$f->default = [];
return $f;
}
/** Binds the block to live ERP data (branches, programs, trainers, ...). */
public static function dataSource(string $key, string $label, array $sources): static
{
return static::make($key, $label, FieldType::DataSource)->options($sources);
}
public function required(bool $v = true): static
{
$this->required = $v;
return $this;
}
public function default(mixed $v): static
{
$this->default = $v;
return $this;
}
public function help(string $v): static
{
$this->help = $v;
return $this;
}
public function options(array $v): static
{
$this->options = $v;
return $this;
}
public function placeholder(string $v): static
{
$this->placeholder = $v;
return $this;
}
public function translatable(bool $v = true): static
{
$this->translatable = $v;
return $this;
}
public function max(int $v): static
{
$this->max = $v;
return $this;
}
public function min(int $v): static
{
$this->min = $v;
return $this;
}
/** Only show this field in the builder when another field has a given value. */
public function showIf(string $otherKey, mixed $value = true): static
{
$this->showIf = $otherKey;
$this->showIfValue = $value;
return $this;
}
/** Laravel validation rules for this field, keyed by the data path. */
public function validationRules(string $prefix = 'data'): array
{
$path = "{$prefix}.{$this->key}";
$rules = [];
if ($this->type === FieldType::Repeater) {
$rules[$path] = 'array'.($this->max ? "|max:{$this->max}" : '');
foreach ($this->fields as $sub) {
$rules += $sub->validationRules("{$path}.*");
}
return $rules;
}
$base = $this->required ? 'required' : 'nullable';
if ($this->translatable) {
// Arabic is the primary locale, so it carries the `required`.
$rules["{$path}.ar"] = $base.'|string';
$rules["{$path}.en"] = 'nullable|string';
return $rules;
}
$rules[$path] = match ($this->type) {
FieldType::Number => $base.'|numeric',
// Link targets are rendered into href attributes, so the scheme is
// constrained here as well as defensively at render time.
FieldType::Link => [$base, 'string', 'max:500', 'regex:/^\s*(https?:\/\/|mailto:|tel:|\/(?!\/)|#|\?)/i'],
FieldType::Toggle => 'boolean',
FieldType::Color => $base.'|string|max:30',
FieldType::Gallery => $base.'|array',
FieldType::Select, FieldType::Radio, FieldType::Alignment => $this->options
? $base.'|in:'.implode(',', array_keys($this->options))
: $base.'|string',
default => $base.'|string',
};
return $rules;
}
}
<?php
namespace App\Domain\Website\Blocks;
use App\Domain\Website\Enums\BlockCategory;
use InvalidArgumentException;
/**
* The single source of truth for which blocks the builder offers.
*
* Registered as a singleton in WebsiteServiceProvider. Because block types are
* plain classes, a client-specific capability can be added by dropping one file
* into Blocks/Types and registering it — nothing in the database changes.
*/
class BlockRegistry
{
/** @var array<string,BlockType> */
protected array $types = [];
public function register(BlockType|string $type): static
{
$instance = is_string($type) ? new $type : $type;
if (! $instance instanceof BlockType) {
throw new InvalidArgumentException('Block types must extend BlockType.');
}
$this->types[$instance->key()] = $instance;
return $this;
}
public function registerMany(array $types): static
{
foreach ($types as $type) {
$this->register($type);
}
return $this;
}
public function has(string $key): bool
{
return isset($this->types[$key]);
}
public function get(string $key): ?BlockType
{
return $this->types[$key] ?? null;
}
/** @throws InvalidArgumentException when the key was never registered. */
public function resolve(string $key): BlockType
{
return $this->types[$key]
?? throw new InvalidArgumentException("Unknown website block type [{$key}].");
}
/** @return array<string,BlockType> */
public function all(): array
{
return $this->types;
}
public function keys(): array
{
return array_keys($this->types);
}
/** Blocks offered in the picker, grouped by category, hidden ones removed. */
public function grouped(): array
{
$grouped = [];
foreach ($this->types as $type) {
if ($type->isHidden()) {
continue;
}
$grouped[$type->category()->value][] = $type;
}
// Present categories in the enum's declared order, not insertion order.
$ordered = [];
foreach (BlockCategory::cases() as $category) {
if (! empty($grouped[$category->value])) {
$ordered[$category->value] = $grouped[$category->value];
}
}
return $ordered;
}
/** @return BlockType[] */
public function inCategory(BlockCategory $category): array
{
return array_values(array_filter(
$this->types,
fn (BlockType $t) => $t->category() === $category && ! $t->isHidden(),
));
}
}
<?php
namespace App\Domain\Website\Blocks;
use App\Domain\Website\Enums\BlockCategory;
/**
* Base class for every block the website builder can place on a page.
*
* Subclassing this and registering it in BlockRegistry is the ONLY step needed
* to add a new capability to the builder — no migration, no enum edit, no
* CHECK constraint. That is the whole point of the v3 builder.
*/
abstract class BlockType
{
/** Stable identifier stored in website_blocks.type. Never rename in place. */
abstract public function key(): string;
/** Arabic label shown in the block picker. */
abstract public function label(): string;
abstract public function category(): BlockCategory;
/** Heroicon name (outline set) for the block picker. */
public function icon(): string
{
return 'square-3-stack-3d';
}
public function description(): string
{
return '';
}
/**
* Layout variants for this block. Every variant maps to a Blade partial at
* website.blocks.{key}.{variant}. Always include a 'default'.
*
* @return array<string,string>
*/
public function variants(): array
{
return ['default' => 'افتراضي'];
}
/** @return BlockField[] */
abstract public function fields(): array;
/** May this block contain other blocks? */
public function allowsChildren(): bool
{
return false;
}
/** Restrict which block types may nest inside. Null = any. */
public function allowedChildTypes(): ?array
{
return null;
}
/** Named slots for container blocks, e.g. ['default'] or ['left','right']. */
public function slots(): array
{
return ['default'];
}
/** Blocks that only make sense once per page (navbar, footer). */
public function isSingleton(): bool
{
return false;
}
/** Hidden from the picker — used for internal/child-only blocks. */
public function isHidden(): bool
{
return false;
}
/** Seed values for a freshly inserted block, derived from field defaults. */
public function defaultData(): array
{
$data = [];
foreach ($this->fields() as $field) {
if ($field->default !== null) {
$data[$field->key] = $field->default;
} elseif ($field->translatable) {
$data[$field->key] = ['ar' => '', 'en' => ''];
}
}
return $data;
}
/**
* Resolves the Blade partial for a variant.
*
* Most variants differ only in utility classes, so the common case is a
* single `website.blocks.{key}` partial that switches on $variant itself.
* A variant that genuinely needs different markup can override by adding
* `website.blocks.{key}.{variant}`, which wins when present.
*/
public function viewFor(string $variant): string
{
$override = "website.blocks.{$this->key()}.{$variant}";
return view()->exists($override)
? $override
: "website.blocks.{$this->key()}";
}
/** Validation rules for this block's data payload. */
public function validationRules(): array
{
$rules = [];
foreach ($this->fields() as $field) {
$rules += $field->validationRules();
}
return $rules;
}
/** Flattened field lookup, including repeater sub-fields. */
public function field(string $key): ?BlockField
{
foreach ($this->fields() as $field) {
if ($field->key === $key) {
return $field;
}
}
return null;
}
}
<?php
namespace App\Domain\Website\Blocks;
use App\Domain\Website\Enums\BlockCategory;
/**
* Base for blocks that render live ERP records (branches, programs, trainers…).
*
* These are what make a site "data-driven": the client edits a branch once in the
* ERP and the website follows, instead of maintaining a second copy of the truth.
*/
abstract class DataBlockType extends BlockType
{
public function category(): BlockCategory
{
return BlockCategory::Data;
}
/** Extra fields specific to the concrete data block. */
protected function extraFields(): array
{
return [];
}
/** Filter controls offered for this source, e.g. by branch or activity. */
protected function filterFields(): array
{
return [];
}
public function fields(): array
{
return array_merge([
BlockField::text('title', 'العنوان'),
BlockField::textarea('subtitle', 'العنوان الفرعي'),
], $this->filterFields(), [
BlockField::number('limit', 'عدد العناصر')->default(0)
->help('صفر = عرض الكل'),
BlockField::select('columns', 'عدد الأعمدة', [
'1' => '1', '2' => '2', '3' => '3', '4' => '4',
])->default('3'),
BlockField::toggle('show_search', 'إظهار مربع بحث'),
BlockField::toggle('show_image', 'إظهار الصور')->default(true),
BlockField::toggle('link_items', 'ربط العناصر بصفحاتها')->default(true),
BlockField::text('empty_message', 'رسالة عند عدم وجود بيانات'),
BlockField::repeater('buttons', 'أزرار أسفل القسم', [
BlockField::text('label', 'النص'),
BlockField::link('url', 'الرابط'),
])->max(2),
], $this->extraFields());
}
}
<?php
namespace App\Domain\Website\Blocks;
/**
* The input controls the builder UI knows how to render.
* Adding a control here + a case in the field-renderer Blade is all that is
* required to give every block type a new kind of input.
*/
enum FieldType: string
{
case Text = 'text';
case Textarea = 'textarea';
case RichText = 'rich_text';
case Number = 'number';
case Toggle = 'toggle';
case Select = 'select';
case Radio = 'radio';
case Color = 'color';
case Image = 'image';
case Gallery = 'gallery';
case Video = 'video';
case Icon = 'icon';
case Link = 'link';
case Date = 'date';
case Alignment = 'alignment';
case Spacing = 'spacing';
case Repeater = 'repeater';
case DataSource = 'data_source';
case Code = 'code';
case Map = 'map';
/** Bilingual controls render two inputs (ar + en) and store {"ar":..,"en":..}. */
public function isTranslatable(): bool
{
return in_array($this, [self::Text, self::Textarea, self::RichText], true);
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
class AccordionBlock extends BlockType
{
public function key(): string
{
return 'accordion';
}
public function label(): string
{
return 'أسئلة شائعة / قائمة منسدلة';
}
public function icon(): string
{
return 'question-mark-circle';
}
public function category(): BlockCategory
{
return BlockCategory::Content;
}
public function variants(): array
{
return [
'bordered' => 'بإطار',
'separated' => 'بطاقات منفصلة',
'minimal' => 'مبسّط',
'two_column' => 'عمودان',
];
}
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::textarea('subtitle', 'العنوان الفرعي'),
BlockField::toggle('single_open', 'فتح عنصر واحد فقط')->default(true),
BlockField::toggle('use_faq_data', 'استخدام الأسئلة من النظام')
->help('يعرض الأسئلة المسجلة في إدارة الموقع بدلًا من الإدخال اليدوي'),
BlockField::repeater('items', 'الأسئلة', [
BlockField::text('question', 'السؤال'),
BlockField::richText('answer', 'الإجابة'),
BlockField::text('category', 'التصنيف'),
])->showIf('use_faq_data', false),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\DataBlockType;
class ActivitiesBlock extends DataBlockType
{
public function key(): string
{
return 'data_activities';
}
public function label(): string
{
return 'الأنشطة الرياضية';
}
public function icon(): string
{
return 'fire';
}
public function variants(): array
{
return [
'tiles' => 'مربعات',
'cards' => 'بطاقات',
'icon_grid' => 'شبكة أيقونات',
'image_overlay' => 'صورة مع تراكب',
'carousel' => 'شرائح',
];
}
protected function extraFields(): array
{
return [
BlockField::toggle('show_description', 'إظهار الوصف')->default(true),
BlockField::toggle('show_program_count', 'إظهار عدد البرامج'),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
/** Mobile-app promotion with store badges — every academy with an app needs this. */
class AppDownloadBlock extends BlockType
{
public function key(): string
{
return 'app_download';
}
public function label(): string
{
return 'تحميل التطبيق';
}
public function icon(): string
{
return 'device-phone-mobile';
}
public function category(): BlockCategory
{
return BlockCategory::Action;
}
public function variants(): array
{
return [
'split' => 'مقسّم مع لقطة',
'centered' => 'وسط',
'banner' => 'شريط',
'floating_mockup' => 'لقطة عائمة',
];
}
public function fields(): array
{
return [
BlockField::text('eyebrow', 'نص علوي صغير'),
BlockField::text('title', 'العنوان')->required(),
BlockField::richText('description', 'الوصف'),
BlockField::image('screenshot', 'لقطة من التطبيق'),
BlockField::text('ios_url', 'رابط App Store'),
BlockField::text('android_url', 'رابط Google Play'),
BlockField::text('huawei_url', 'رابط AppGallery'),
// Official store badges are supplied as artwork by the stores
// themselves; upload them rather than approximating the marks.
BlockField::image('ios_badge', 'صورة شارة App Store'),
BlockField::image('android_badge', 'صورة شارة Google Play'),
BlockField::image('huawei_badge', 'صورة شارة AppGallery'),
BlockField::repeater('features', 'مميزات التطبيق', [
BlockField::icon('icon', 'أيقونة'),
BlockField::text('title', 'العنوان'),
BlockField::text('body', 'الوصف'),
]),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\DataBlockType;
class BranchesBlock extends DataBlockType
{
public function key(): string
{
return 'data_branches';
}
public function label(): string
{
return 'الفروع';
}
public function description(): string
{
return 'يعرض الفروع المسجلة في النظام تلقائيًا';
}
public function icon(): string
{
return 'map-pin';
}
public function variants(): array
{
return [
'cards' => 'بطاقات',
'list' => 'قائمة',
'map_split' => 'مع خريطة',
'carousel' => 'شرائح',
'compact' => 'مضغوط',
];
}
protected function extraFields(): array
{
return [
BlockField::toggle('show_location', 'إظهار الموقع')->default(true),
BlockField::toggle('show_phone', 'إظهار الهاتف'),
BlockField::toggle('show_manager', 'إظهار اسم المسؤول'),
BlockField::toggle('show_hours', 'إظهار مواعيد العمل'),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
/**
* Repeatable cards. Covers vision/mission/values, service tiles, feature grids —
* anything that is "N similar things side by side".
*/
class CardGridBlock extends BlockType
{
public function key(): string
{
return 'card_grid';
}
public function label(): string
{
return 'شبكة بطاقات';
}
public function description(): string
{
return 'بطاقات متكررة: الرؤية والرسالة، المميزات، الخدمات';
}
public function icon(): string
{
return 'rectangle-group';
}
public function category(): BlockCategory
{
return BlockCategory::Content;
}
public function variants(): array
{
return [
'cards' => 'بطاقات',
'bordered' => 'بإطار',
'icon_top' => 'أيقونة بالأعلى',
'icon_start' => 'أيقونة بالجانب',
'numbered' => 'مرقّمة',
'glass' => 'زجاجي',
'minimal' => 'مبسّط',
];
}
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::textarea('subtitle', 'العنوان الفرعي'),
BlockField::select('columns', 'عدد الأعمدة', [
'1' => '1', '2' => '2', '3' => '3', '4' => '4',
])->default('3'),
BlockField::repeater('items', 'البطاقات', [
BlockField::icon('icon', 'أيقونة'),
BlockField::image('image', 'صورة'),
BlockField::text('title', 'العنوان'),
BlockField::richText('body', 'النص'),
BlockField::link('url', 'رابط'),
BlockField::color('accent', 'لون مميز'),
])->required(),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
/** Multi-column layout. Each column is a named slot that accepts any blocks. */
class ColumnsBlock extends BlockType
{
public function key(): string
{
return 'columns';
}
public function label(): string
{
return 'أعمدة';
}
public function description(): string
{
return 'تقسيم المساحة إلى أعمدة، كل عمود يقبل أي عناصر';
}
public function icon(): string
{
return 'view-columns';
}
public function category(): BlockCategory
{
return BlockCategory::Layout;
}
public function allowsChildren(): bool
{
return true;
}
public function slots(): array
{
return ['col1', 'col2', 'col3', 'col4'];
}
public function variants(): array
{
return [
'two' => 'عمودان',
'two_wide_start' => 'عمودان (الأول أعرض)',
'two_wide_end' => 'عمودان (الثاني أعرض)',
'three' => 'ثلاثة أعمدة',
'four' => 'أربعة أعمدة',
];
}
public function fields(): array
{
return [
BlockField::select('gap', 'المسافة بين الأعمدة', [
'none' => 'بدون', 'sm' => 'صغيرة', 'md' => 'متوسطة', 'lg' => 'كبيرة',
])->default('md'),
BlockField::select('vertical_align', 'المحاذاة الرأسية', [
'start' => 'أعلى', 'center' => 'وسط', 'stretch' => 'تمديد',
])->default('stretch'),
BlockField::toggle('stack_on_mobile', 'تكديس على الجوال')->default(true),
BlockField::toggle('reverse_on_mobile', 'عكس الترتيب على الجوال'),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
/**
* Contact form. Submissions land in the existing website_contact_submissions
* table so the admin inbox keeps working unchanged.
*/
class ContactFormBlock extends BlockType
{
public function key(): string
{
return 'contact_form';
}
public function label(): string
{
return 'نموذج تواصل';
}
public function icon(): string
{
return 'envelope';
}
public function category(): BlockCategory
{
return BlockCategory::Action;
}
public function variants(): array
{
return [
'stacked' => 'عمودي',
'split_info' => 'مع بيانات التواصل',
'boxed' => 'صندوق',
'inline' => 'مضغوط',
];
}
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::textarea('description', 'الوصف'),
BlockField::text('submit_label', 'نص زر الإرسال')
->default(['ar' => 'إرسال', 'en' => 'Send']),
BlockField::textarea('success_message', 'رسالة النجاح'),
BlockField::toggle('show_subject', 'إظهار حقل الموضوع')->default(true),
BlockField::toggle('show_phone', 'إظهار حقل الهاتف')->default(true),
BlockField::toggle('require_phone', 'الهاتف مطلوب'),
BlockField::text('notify_email', 'إرسال إشعار إلى'),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
class CtaBlock extends BlockType
{
public function key(): string
{
return 'cta';
}
public function label(): string
{
return 'دعوة لإجراء';
}
public function icon(): string
{
return 'megaphone';
}
public function category(): BlockCategory
{
return BlockCategory::Action;
}
public function variants(): array
{
return [
'banner' => 'شريط',
'card' => 'بطاقة',
'split' => 'مقسّم',
'gradient' => 'تدرج لوني',
'image_bg' => 'خلفية صورة',
'boxed' => 'صندوق بارز',
];
}
public function fields(): array
{
return [
BlockField::text('title', 'العنوان')->required(),
BlockField::textarea('description', 'الوصف'),
BlockField::image('image', 'صورة'),
BlockField::alignment('align', 'المحاذاة'),
BlockField::repeater('buttons', 'الأزرار', [
BlockField::text('label', 'النص'),
BlockField::link('url', 'الرابط'),
BlockField::select('style', 'النمط', [
'primary' => 'أساسي', 'secondary' => 'ثانوي',
'outline' => 'محدد', 'white' => 'أبيض',
])->default('primary'),
BlockField::icon('icon', 'أيقونة'),
])->required()->max(3),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
/**
* Escape hatch for anything the block catalogue does not cover yet.
* Rendered sanitised — see BlockRenderer — so a pasted snippet cannot inject
* script into a tenant's public site.
*/
class CustomHtmlBlock extends BlockType
{
public function key(): string
{
return 'custom_html';
}
public function label(): string
{
return 'كود مخصص';
}
public function icon(): string
{
return 'code-bracket';
}
public function category(): BlockCategory
{
return BlockCategory::Advanced;
}
public function fields(): array
{
return [
BlockField::code('html', 'HTML'),
BlockField::toggle('full_width', 'عرض كامل'),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
/** Separator between sections — includes the shaped dividers their site uses. */
class DividerBlock extends BlockType
{
public function key(): string
{
return 'divider';
}
public function label(): string
{
return 'فاصل';
}
public function icon(): string
{
return 'minus';
}
public function category(): BlockCategory
{
return BlockCategory::Layout;
}
public function variants(): array
{
return [
'line' => 'خط',
'wave' => 'موجة',
'angle' => 'مائل',
'curve' => 'منحنى',
'zigzag' => 'متعرج',
'dots' => 'نقاط',
];
}
public function fields(): array
{
return [
BlockField::color('color', 'اللون'),
BlockField::select('size', 'الحجم', [
'sm' => 'صغير', 'md' => 'متوسط', 'lg' => 'كبير',
])->default('md'),
BlockField::toggle('flip', 'قلب رأسي'),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
/** Third-party embeds via an allow-listed provider set. */
class EmbedBlock extends BlockType
{
public function key(): string
{
return 'embed';
}
public function label(): string
{
return 'تضمين خارجي';
}
public function icon(): string
{
return 'globe-alt';
}
public function category(): BlockCategory
{
return BlockCategory::Advanced;
}
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::select('provider', 'المصدر', [
'youtube' => 'يوتيوب',
'vimeo' => 'Vimeo',
'instagram' => 'إنستجرام',
'facebook' => 'فيسبوك',
'google_maps' => 'خرائط جوجل',
'google_form' => 'نماذج جوجل',
])->required(),
BlockField::text('url', 'الرابط')->required(),
BlockField::select('ratio', 'نسبة العرض', [
'16:9' => '16:9', '4:3' => '4:3', '1:1' => '1:1', 'auto' => 'تلقائي',
])->default('16:9'),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\DataBlockType;
class EventsBlock extends DataBlockType
{
public function key(): string
{
return 'data_events';
}
public function label(): string
{
return 'الفعاليات والبطولات';
}
public function icon(): string
{
return 'trophy';
}
public function variants(): array
{
return ['cards' => 'بطاقات', 'list' => 'قائمة', 'featured' => 'فعالية مميزة', 'countdown' => 'مع عد تنازلي'];
}
protected function extraFields(): array
{
return [
BlockField::toggle('upcoming_only', 'القادمة فقط')->default(true),
BlockField::toggle('show_countdown', 'إظهار العد التنازلي'),
BlockField::toggle('show_register_button', 'إظهار زر التسجيل')->default(true),
BlockField::toggle('show_location', 'إظهار المكان')->default(true),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
/**
* Photo gallery. Supports manual uploads or pulling a category from the media
* library, which is what "upload photos from activities" actually needs.
*/
class GalleryBlock extends BlockType
{
public function key(): string
{
return 'gallery';
}
public function label(): string
{
return 'معرض صور';
}
public function icon(): string
{
return 'camera';
}
public function category(): BlockCategory
{
return BlockCategory::Media;
}
public function variants(): array
{
return [
'grid' => 'شبكة',
'masonry' => 'متداخل',
'carousel' => 'شرائح',
'marquee' => 'شريط متحرك',
'filmstrip' => 'شريط أفقي',
'lightbox_grid' => 'شبكة مع تكبير',
'featured' => 'صورة كبيرة + مصغرات',
];
}
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::textarea('subtitle', 'العنوان الفرعي'),
BlockField::radio('source', 'مصدر الصور', [
'manual' => 'رفع يدوي',
'collection' => 'من مكتبة الوسائط',
])->default('manual'),
BlockField::gallery('images', 'الصور')->showIf('source', 'manual'),
BlockField::select('collection', 'التصنيف', [
'gallery' => 'معرض الصور',
'activity_photo' => 'صور الأنشطة',
'branch_photo' => 'صور الفروع',
'team_photo' => 'صور الفريق',
'event_gallery' => 'صور الفعاليات',
])->showIf('source', 'collection'),
BlockField::number('limit', 'عدد الصور')->default(12),
BlockField::select('columns', 'عدد الأعمدة', [
'2' => '2', '3' => '3', '4' => '4', '5' => '5', '6' => '6',
])->default('4'),
BlockField::select('aspect', 'نسبة العرض', [
'square' => 'مربع', 'landscape' => 'عرضي',
'portrait' => 'طولي', 'auto' => 'طبيعي',
])->default('square'),
BlockField::toggle('show_captions', 'إظهار التعليقات'),
BlockField::toggle('enable_lightbox', 'تكبير عند الضغط')->default(true),
BlockField::toggle('filterable', 'إتاحة التصفية حسب التصنيف'),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
class HeroBlock extends BlockType
{
public function key(): string
{
return 'hero';
}
public function label(): string
{
return 'الواجهة الرئيسية';
}
public function icon(): string
{
return 'photo';
}
public function category(): BlockCategory
{
return BlockCategory::Hero;
}
public function variants(): array
{
return [
'fullscreen' => 'ملء الشاشة',
'split_start' => 'مقسّم — الصورة في البداية',
'split_end' => 'مقسّم — الصورة في النهاية',
'centered_minimal' => 'وسط مبسّط',
'slideshow' => 'شرائح متحركة',
'video_bg' => 'خلفية فيديو',
'product_showcase' => 'عرض منتج بألوان متعددة',
];
}
public function fields(): array
{
return [
BlockField::text('eyebrow', 'نص علوي صغير')
->help('مثال: مرحبًا بك في'),
BlockField::text('title', 'العنوان الرئيسي')->required(),
BlockField::textarea('subtitle', 'العنوان الفرعي'),
BlockField::image('image', 'الصورة')
->showIf('__variant_uses_image'),
BlockField::gallery('slides', 'الشرائح'),
BlockField::video('video_url', 'رابط الفيديو'),
BlockField::select('height', 'الارتفاع', [
'auto' => 'تلقائي', 'half' => 'نصف الشاشة',
'three_quarter' => 'ثلاثة أرباع', 'full' => 'ملء الشاشة',
])->default('three_quarter'),
BlockField::alignment('align', 'محاذاة النص'),
BlockField::toggle('show_scroll_hint', 'إظهار سهم التمرير')->default(true),
// Multi-colour product showcase (e.g. a kit in several colourways).
BlockField::repeater('products', 'المنتجات المعروضة', [
BlockField::text('name', 'الاسم'),
BlockField::color('swatch', 'لون الاختيار'),
BlockField::image('front', 'صورة أمامية'),
BlockField::image('back', 'صورة خلفية'),
])->help('لعرض منتج بألوان متعددة مع إمكانية التبديل بينها'),
BlockField::repeater('buttons', 'الأزرار', [
BlockField::text('label', 'النص'),
BlockField::link('url', 'الرابط'),
BlockField::select('style', 'النمط', [
'primary' => 'أساسي', 'secondary' => 'ثانوي',
'outline' => 'محدد', 'ghost' => 'شفاف',
])->default('primary'),
BlockField::icon('icon', 'أيقونة'),
])->max(3),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
/** Phone / email / address tiles, each optionally actionable. */
class InfoCardsBlock extends BlockType
{
public function key(): string
{
return 'info_cards';
}
public function label(): string
{
return 'بطاقات معلومات التواصل';
}
public function icon(): string
{
return 'phone';
}
public function category(): BlockCategory
{
return BlockCategory::Social;
}
public function variants(): array
{
return [
'cards' => 'بطاقات',
'inline' => 'صف واحد',
'bordered' => 'بإطار',
'icon_circle' => 'أيقونة دائرية',
];
}
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::repeater('items', 'البطاقات', [
BlockField::icon('icon', 'أيقونة'),
BlockField::text('label', 'التسمية'),
BlockField::text('value', 'القيمة'),
BlockField::select('action', 'نوع الإجراء', [
'none' => 'بدون',
'tel' => 'اتصال',
'mailto' => 'بريد',
'whatsapp' => 'واتساب',
'maps' => 'خرائط',
'url' => 'رابط',
])->default('none'),
BlockField::text('action_value', 'قيمة الإجراء'),
])->required(),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
/** Partner / sponsor logos. */
class LogoStripBlock extends BlockType
{
public function key(): string
{
return 'logo_strip';
}
public function label(): string
{
return 'شعارات الشركاء';
}
public function icon(): string
{
return 'building-office';
}
public function category(): BlockCategory
{
return BlockCategory::Social;
}
public function variants(): array
{
return [
'grid' => 'شبكة',
'marquee' => 'شريط متحرك',
'centered' => 'وسط',
'bordered' => 'بفواصل',
];
}
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::textarea('subtitle', 'العنوان الفرعي'),
BlockField::toggle('use_partner_data', 'استخدام الشركاء من النظام')->default(true),
BlockField::repeater('logos', 'الشعارات', [
BlockField::image('logo', 'الشعار'),
BlockField::text('name', 'الاسم'),
BlockField::link('url', 'الرابط'),
])->showIf('use_partner_data', false),
BlockField::toggle('grayscale', 'تدرج رمادي حتى التمرير')->default(true),
BlockField::select('speed', 'سرعة الحركة', [
'slow' => 'بطيئة', 'normal' => 'عادية', 'fast' => 'سريعة',
])->default('normal'),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
class MapBlock extends BlockType
{
public function key(): string
{
return 'map';
}
public function label(): string
{
return 'خريطة';
}
public function icon(): string
{
return 'map-pin';
}
public function category(): BlockCategory
{
return BlockCategory::Social;
}
public function variants(): array
{
return [
'embed' => 'مضمّنة',
'full_width' => 'عرض كامل',
'split' => 'مع بيانات جانبية',
];
}
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::toggle('use_branch_data', 'استخدام مواقع الفروع من النظام')->default(true),
BlockField::repeater('locations', 'المواقع', [
BlockField::text('name', 'الاسم'),
BlockField::text('address', 'العنوان'),
BlockField::map('coords', 'الإحداثيات'),
])->showIf('use_branch_data', false),
BlockField::select('height', 'الارتفاع', [
'sm' => 'صغير', 'md' => 'متوسط', 'lg' => 'كبير',
])->default('md'),
BlockField::number('zoom', 'مستوى التقريب')->default(13),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\DataBlockType;
class NewsBlock extends DataBlockType
{
public function key(): string
{
return 'data_news';
}
public function label(): string
{
return 'الأخبار';
}
public function icon(): string
{
return 'newspaper';
}
public function variants(): array
{
return ['cards' => 'بطاقات', 'list' => 'قائمة', 'featured' => 'خبر مميز + قائمة', 'carousel' => 'شرائح'];
}
protected function extraFields(): array
{
return [
BlockField::text('category_filter', 'تصفية حسب التصنيف'),
BlockField::toggle('show_date', 'إظهار التاريخ')->default(true),
BlockField::toggle('show_excerpt', 'إظهار المقتطف')->default(true),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
/**
* Pricing tiers. Prices may be typed manually or pulled from the pricing engine's
* base prices, so a published price cannot silently drift from what the POS charges.
*/
class PricingBlock extends BlockType
{
public function key(): string
{
return 'pricing';
}
public function label(): string
{
return 'الأسعار والباقات';
}
public function icon(): string
{
return 'currency-dollar';
}
public function category(): BlockCategory
{
return BlockCategory::Commerce;
}
public function variants(): array
{
return ['cards' => 'بطاقات', 'table' => 'جدول مقارنة', 'highlighted' => 'مع باقة مميزة', 'compact' => 'مضغوط'];
}
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::textarea('subtitle', 'العنوان الفرعي'),
BlockField::text('currency_label', 'رمز العملة')->default(['ar' => 'ج.م', 'en' => 'EGP']),
BlockField::radio('price_source', 'مصدر الأسعار', [
'manual' => 'إدخال يدوي',
'base_prices' => 'من محرك التسعير',
])->default('manual'),
BlockField::repeater('plans', 'الباقات', [
BlockField::text('name', 'الاسم'),
BlockField::text('price', 'السعر'),
BlockField::text('period', 'المدة')->help('مثال: شهريًا'),
BlockField::textarea('description', 'الوصف'),
BlockField::toggle('featured', 'باقة مميزة'),
BlockField::text('badge', 'شارة'),
BlockField::repeater('features', 'المميزات', [
BlockField::text('text', 'الميزة'),
BlockField::toggle('included', 'مشمولة')->default(true),
]),
BlockField::text('button_label', 'نص الزر'),
BlockField::link('button_url', 'رابط الزر'),
]),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
/**
* A featured person: chairman, founder, head coach.
* Carries free-form credential rows and an achievements list, so it fits any
* organisation without the fields being named for one client's org chart.
*/
class ProfileCardBlock extends BlockType
{
public function key(): string
{
return 'profile_card';
}
public function label(): string
{
return 'بطاقة شخصية';
}
public function description(): string
{
return 'لعرض رئيس مجلس الإدارة أو المؤسس أو شخصية بارزة';
}
public function icon(): string
{
return 'identification';
}
public function category(): BlockCategory
{
return BlockCategory::People;
}
public function variants(): array
{
return [
'split' => 'مقسّم',
'centered' => 'وسط',
'card' => 'بطاقة',
'quote_focus' => 'التركيز على الاقتباس',
];
}
public function fields(): array
{
return [
BlockField::text('section_title', 'عنوان القسم'),
BlockField::image('photo', 'الصورة')->required(),
BlockField::text('name', 'الاسم')->required(),
BlockField::text('role', 'المنصب'),
BlockField::text('badge', 'شارة')->help('مثال: منذ 2008'),
BlockField::richText('bio', 'نبذة'),
BlockField::textarea('quote', 'اقتباس'),
// Free-form label/value rows — birth date, education, specialisation…
BlockField::repeater('details', 'بيانات', [
BlockField::icon('icon', 'أيقونة'),
BlockField::text('label', 'التسمية'),
BlockField::text('value', 'القيمة'),
]),
BlockField::repeater('achievements', 'الإنجازات', [
BlockField::text('text', 'الإنجاز'),
]),
BlockField::repeater('social', 'روابط التواصل', [
BlockField::icon('icon', 'المنصة'),
BlockField::link('url', 'الرابط'),
]),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\DataBlockType;
class ProgramsBlock extends DataBlockType
{
public function key(): string
{
return 'data_programs';
}
public function label(): string
{
return 'البرامج التدريبية';
}
public function icon(): string
{
return 'academic-cap';
}
public function variants(): array
{
return ['cards' => 'بطاقات', 'list' => 'قائمة', 'carousel' => 'شرائح', 'featured' => 'مميز'];
}
protected function filterFields(): array
{
return [
BlockField::dataSource('branch_filter', 'تصفية حسب الفرع', ['branches' => 'الفروع']),
BlockField::dataSource('activity_filter', 'تصفية حسب النشاط', ['activities' => 'الأنشطة']),
];
}
protected function extraFields(): array
{
return [
BlockField::toggle('show_price', 'إظهار السعر'),
BlockField::toggle('show_age_range', 'إظهار الفئة العمرية')->default(true),
BlockField::toggle('show_duration', 'إظهار المدة'),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
/** Long-form prose: terms, refund policy, privacy, articles. */
class RichTextBlock extends BlockType
{
public function key(): string
{
return 'rich_text';
}
public function label(): string
{
return 'نص منسّق';
}
public function description(): string
{
return 'للصفحات الطويلة: الشروط والأحكام، سياسة الاسترجاع، الخصوصية';
}
public function icon(): string
{
return 'document-text';
}
public function category(): BlockCategory
{
return BlockCategory::Content;
}
public function variants(): array
{
return [
'default' => 'عادي',
'prose_narrow' => 'عمود ضيق للقراءة',
'two_column' => 'عمودان',
'with_toc' => 'مع فهرس جانبي',
];
}
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::text('last_updated', 'آخر تحديث'),
BlockField::richText('body', 'المحتوى'),
// Numbered legal clauses, each optionally deep-linkable.
BlockField::repeater('sections', 'أقسام', [
BlockField::text('heading', 'عنوان القسم'),
BlockField::richText('body', 'النص'),
BlockField::text('anchor', 'معرّف الرابط'),
]),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\DataBlockType;
/**
* Public training timetable. The `cards` variant renders one uploaded image per
* session group — the pattern academies already use for social-media schedules.
*/
class ScheduleBlock extends DataBlockType
{
public function key(): string
{
return 'data_schedule';
}
public function label(): string
{
return 'جدول المواعيد';
}
public function icon(): string
{
return 'calendar';
}
public function variants(): array
{
return [
'table' => 'جدول',
'by_branch' => 'مجمّع حسب الفرع',
'by_day' => 'مجمّع حسب اليوم',
'cards' => 'بطاقات مصوّرة',
'timeline' => 'خط زمني',
];
}
protected function filterFields(): array
{
return [
BlockField::dataSource('branch_filter', 'تصفية حسب الفرع', ['branches' => 'الفروع']),
BlockField::dataSource('activity_filter', 'تصفية حسب النشاط', ['activities' => 'الأنشطة']),
];
}
protected function extraFields(): array
{
return [
BlockField::toggle('show_age_groups', 'إظهار الفئات العمرية')->default(true),
BlockField::toggle('show_times', 'إظهار الأوقات')->default(true),
BlockField::toggle('show_days', 'إظهار الأيام')->default(true),
BlockField::toggle('group_image_enabled', 'إظهار صورة المجموعة')
->help('يعرض الصورة المرفوعة لكل مجموعة تدريبية'),
BlockField::toggle('enable_lightbox', 'تكبير الصورة عند الضغط')->default(true),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
/** Generic full-width container. The backbone of every custom layout. */
class SectionBlock extends BlockType
{
public function key(): string
{
return 'section';
}
public function label(): string
{
return 'قسم';
}
public function description(): string
{
return 'حاوية عامة يمكن وضع أي عناصر بداخلها';
}
public function icon(): string
{
return 'squares-2x2';
}
public function category(): BlockCategory
{
return BlockCategory::Layout;
}
public function allowsChildren(): bool
{
return true;
}
public function variants(): array
{
return [
'default' => 'عرض الحاوية',
'full_bleed' => 'عرض كامل',
'narrow' => 'ضيق',
];
}
public function fields(): array
{
return [
BlockField::text('eyebrow', 'نص علوي صغير'),
BlockField::text('title', 'العنوان'),
BlockField::textarea('subtitle', 'العنوان الفرعي'),
BlockField::alignment('header_align', 'محاذاة العنوان'),
BlockField::select('max_width', 'أقصى عرض', [
'sm' => 'صغير', 'md' => 'متوسط', 'lg' => 'كبير',
'xl' => 'كبير جدًا', 'full' => 'كامل',
])->default('lg'),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
class SocialLinksBlock extends BlockType
{
public function key(): string
{
return 'social_links';
}
public function label(): string
{
return 'روابط التواصل الاجتماعي';
}
public function icon(): string
{
return 'share';
}
public function category(): BlockCategory
{
return BlockCategory::Social;
}
public function variants(): array
{
return ['icons' => 'أيقونات', 'buttons' => 'أزرار', 'cards' => 'بطاقات', 'inline' => 'صف واحد'];
}
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::toggle('use_settings', 'استخدام الروابط من إعدادات الموقع')->default(true),
BlockField::repeater('links', 'الروابط', [
BlockField::icon('platform', 'المنصة'),
BlockField::link('url', 'الرابط'),
BlockField::text('label', 'التسمية'),
])->showIf('use_settings', false),
BlockField::select('size', 'الحجم', ['sm' => 'صغير', 'md' => 'متوسط', 'lg' => 'كبير'])->default('md'),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
class SpacerBlock extends BlockType
{
public function key(): string
{
return 'spacer';
}
public function label(): string
{
return 'مسافة';
}
public function icon(): string
{
return 'arrows-up-down';
}
public function category(): BlockCategory
{
return BlockCategory::Layout;
}
public function fields(): array
{
return [
BlockField::select('height', 'الارتفاع', [
'xs' => 'صغير جدًا', 'sm' => 'صغير', 'md' => 'متوسط',
'lg' => 'كبير', 'xl' => 'كبير جدًا',
])->default('md'),
BlockField::toggle('hide_on_mobile', 'إخفاء على الجوال'),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
class StatsBlock extends BlockType
{
public function key(): string
{
return 'stats';
}
public function label(): string
{
return 'أرقام وإحصائيات';
}
public function icon(): string
{
return 'chart-bar';
}
public function category(): BlockCategory
{
return BlockCategory::Content;
}
public function variants(): array
{
return [
'inline' => 'صف واحد',
'cards' => 'بطاقات',
'bordered' => 'بفواصل',
'big_numbers' => 'أرقام كبيرة',
];
}
public function fields(): array
{
return [
BlockField::text('title', 'العنوان'),
BlockField::toggle('animate_count', 'تحريك العد التصاعدي')->default(true),
BlockField::repeater('items', 'الأرقام', [
BlockField::icon('icon', 'أيقونة'),
BlockField::text('value', 'القيمة'),
BlockField::text('label', 'التسمية'),
BlockField::text('suffix', 'لاحقة')->help('مثال: + أو %'),
BlockField::select('source', 'المصدر', [
'manual' => 'يدوي',
'participants' => 'عدد اللاعبين (تلقائي)',
'programs' => 'عدد البرامج (تلقائي)',
'branches' => 'عدد الفروع (تلقائي)',
'trainers' => 'عدد المدربين (تلقائي)',
])->default('manual'),
])->required(),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\DataBlockType;
use App\Domain\Website\Enums\BlockCategory;
class TestimonialsBlock extends DataBlockType
{
public function key(): string
{
return 'data_testimonials';
}
public function label(): string
{
return 'آراء العملاء';
}
public function icon(): string
{
return 'chat-bubble-left-right';
}
public function category(): BlockCategory
{
return BlockCategory::People;
}
public function variants(): array
{
return ['cards' => 'بطاقات', 'carousel' => 'شرائح', 'masonry' => 'متداخل', 'single_large' => 'اقتباس كبير', 'marquee' => 'شريط متحرك'];
}
protected function extraFields(): array
{
return [
BlockField::toggle('show_rating', 'إظهار التقييم')->default(true),
BlockField::toggle('show_avatar', 'إظهار الصورة')->default(true),
BlockField::toggle('featured_only', 'المميزة فقط')->default(true),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
/** Image beside prose — the classic "about us" arrangement. */
class TextImageBlock extends BlockType
{
public function key(): string
{
return 'text_image';
}
public function label(): string
{
return 'نص وصورة';
}
public function icon(): string
{
return 'photo';
}
public function category(): BlockCategory
{
return BlockCategory::Content;
}
public function variants(): array
{
return [
'image_end' => 'الصورة في النهاية',
'image_start' => 'الصورة في البداية',
'image_background' => 'الصورة كخلفية',
'overlap' => 'متداخل',
];
}
public function fields(): array
{
return [
BlockField::text('eyebrow', 'نص علوي صغير'),
BlockField::text('title', 'العنوان')->required(),
BlockField::richText('body', 'النص'),
BlockField::image('image', 'الصورة')->required(),
BlockField::text('image_caption', 'تعليق على الصورة'),
BlockField::select('image_ratio', 'نسبة الصورة', [
'square' => 'مربع', 'portrait' => 'طولي',
'landscape' => 'عرضي', 'wide' => 'عريض', 'auto' => 'تلقائي',
])->default('landscape'),
BlockField::repeater('bullets', 'نقاط', [
BlockField::icon('icon', 'أيقونة'),
BlockField::text('text', 'النص'),
]),
BlockField::repeater('buttons', 'الأزرار', [
BlockField::text('label', 'النص'),
BlockField::link('url', 'الرابط'),
BlockField::select('style', 'النمط', [
'primary' => 'أساسي', 'secondary' => 'ثانوي', 'outline' => 'محدد',
])->default('primary'),
])->max(2),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\DataBlockType;
use App\Domain\Website\Enums\BlockCategory;
class TrainersBlock extends DataBlockType
{
public function key(): string
{
return 'data_trainers';
}
public function label(): string
{
return 'المدربون';
}
public function icon(): string
{
return 'users';
}
public function category(): BlockCategory
{
return BlockCategory::People;
}
public function variants(): array
{
return [
'grid' => 'شبكة',
'cards' => 'بطاقات',
'circles' => 'صور دائرية',
'carousel' => 'شرائح',
'detailed' => 'مفصّل',
];
}
protected function filterFields(): array
{
return [
BlockField::dataSource('branch_filter', 'تصفية حسب الفرع', ['branches' => 'الفروع']),
];
}
protected function extraFields(): array
{
return [
BlockField::toggle('show_role', 'إظهار المسمى الوظيفي')->default(true),
BlockField::toggle('show_bio', 'إظهار النبذة'),
BlockField::toggle('show_social', 'إظهار روابط التواصل'),
BlockField::toggle('featured_only', 'المميزون فقط'),
];
}
}
<?php
namespace App\Domain\Website\Blocks\Types;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Enums\BlockCategory;
class VideoBlock extends BlockType
{
public function key(): string
{
return 'video';
}
public function label(): string
{
return 'فيديو';
}
public function icon(): string
{
return 'play-circle';
}
public function category(): BlockCategory
{
return BlockCategory::Media;
}
public function variants(): array
{
return [
'embed' => 'مضمّن',
'with_quote' => 'مع اقتباس',
'background' => 'خلفية',
'popup' => 'يفتح في نافذة',
'split' => 'مقسّم مع نص',
];
}
public function fields(): array
{
return [
BlockField::text('eyebrow', 'نص علوي صغير'),
BlockField::text('title', 'العنوان'),
BlockField::video('url', 'رابط الفيديو')->required()
->help('يدعم يوتيوب و Vimeo أو رابط ملف مباشر'),
BlockField::image('poster', 'صورة الغلاف'),
BlockField::textarea('quote', 'اقتباس'),
BlockField::text('quote_author', 'قائل الاقتباس'),
BlockField::select('ratio', 'نسبة العرض', [
'16:9' => '16:9', '4:3' => '4:3', '1:1' => '1:1', '21:9' => '21:9',
])->default('16:9'),
BlockField::toggle('autoplay', 'تشغيل تلقائي'),
BlockField::toggle('muted', 'كتم الصوت')->default(true),
BlockField::toggle('loop', 'تكرار'),
];
}
}
<?php
namespace App\Domain\Website\Enums;
enum BlockCategory: string
{
case Layout = 'layout';
case Hero = 'hero';
case Content = 'content';
case Media = 'media';
case People = 'people';
case Data = 'data';
case Commerce = 'commerce';
case Social = 'social';
case Action = 'action';
case Advanced = 'advanced';
public function label(): string
{
return match ($this) {
self::Layout => 'التخطيط',
self::Hero => 'الواجهة الرئيسية',
self::Content => 'المحتوى',
self::Media => 'الوسائط',
self::People => 'الأشخاص',
self::Data => 'بيانات النظام',
self::Commerce => 'الأسعار والمتجر',
self::Social => 'التواصل الاجتماعي',
self::Action => 'الدعوة لإجراء',
self::Advanced => 'متقدم',
};
}
public function icon(): string
{
return match ($this) {
self::Layout => 'squares-2x2',
self::Hero => 'photo',
self::Content => 'document-text',
self::Media => 'camera',
self::People => 'users',
self::Data => 'circle-stack',
self::Commerce => 'currency-dollar',
self::Social => 'share',
self::Action => 'megaphone',
self::Advanced => 'code-bracket',
};
}
}
...@@ -18,6 +18,7 @@ class ContactSubmission extends Model ...@@ -18,6 +18,7 @@ class ContactSubmission extends Model
'name', 'name',
'phone', 'phone',
'email', 'email',
'subject',
'message', 'message',
'status', 'status',
'admin_notes', 'admin_notes',
......
<?php
namespace App\Domain\Website\Models;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Domain\Website\Blocks\BlockRegistry;
use App\Domain\Website\Blocks\BlockType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class WebsiteBlock extends Model
{
use BelongsToAcademy, HasUuid;
protected $fillable = [
'academy_id',
'website_page_id',
'parent_id',
'slot',
'type',
'variant',
'sort_order',
'is_enabled',
'data',
'style',
];
protected $casts = [
'data' => 'array',
'style' => 'array',
'sort_order' => 'integer',
'is_enabled' => 'boolean',
];
public function page(): BelongsTo
{
return $this->belongsTo(WebsitePage::class, 'website_page_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order');
}
/** Eager-loadable recursive tree. */
public function childrenRecursive(): HasMany
{
return $this->children()->with('childrenRecursive');
}
public function scopeEnabled($query)
{
return $query->where('is_enabled', true);
}
public function scopeOrdered($query)
{
return $query->orderBy('sort_order');
}
/**
* The block's definition. Returns null for a type that is no longer
* registered, so an orphaned row degrades to "skipped" rather than fatal.
*/
public function definition(): ?BlockType
{
return app(BlockRegistry::class)->get($this->type);
}
/** Children grouped by slot name, for multi-slot container blocks. */
public function childrenInSlot(string $slot = 'default')
{
return $this->children->where('slot', $slot);
}
/**
* Read a content field, resolving bilingual values for the active locale.
* Falls back to the other locale rather than rendering an empty element.
*/
public function get(string $key, mixed $default = null, ?string $locale = null): mixed
{
$value = data_get($this->data, $key, $default);
if (! is_array($value) || ! array_key_exists('ar', $value)) {
return $value;
}
$locale ??= app()->getLocale();
$primary = $locale === 'ar' ? 'ar' : 'en';
$fallback = $primary === 'ar' ? 'en' : 'ar';
return filled($value[$primary] ?? null)
? $value[$primary]
: ($value[$fallback] ?? $default);
}
/** Read a presentation setting from the style payload. */
public function style(string $key, mixed $default = null): mixed
{
return data_get($this->style, $key, $default);
}
public function isRenderable(): bool
{
return $this->is_enabled && $this->definition() !== null;
}
}
<?php
namespace App\Domain\Website\Models;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class WebsiteMenu extends Model
{
use BelongsToAcademy, HasUuid;
protected $fillable = ['academy_id', 'key', 'name'];
/** Top-level items only; children are loaded through the tree. */
public function items(): HasMany
{
return $this->hasMany(WebsiteMenuItem::class)
->whereNull('parent_id')
->orderBy('sort_order');
}
public function allItems(): HasMany
{
return $this->hasMany(WebsiteMenuItem::class);
}
public static function labelFor(string $key): string
{
return match ($key) {
'primary' => 'القائمة الرئيسية',
'footer' => 'قائمة التذييل',
'footer_secondary' => 'قائمة التذييل الثانوية',
'mobile' => 'قائمة الجوال',
'utility' => 'قائمة مساعدة',
default => $key,
};
}
}
<?php
namespace App\Domain\Website\Models;
use App\Domain\Shared\Traits\BelongsToAcademy;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class WebsiteMenuItem extends Model
{
use BelongsToAcademy;
protected $fillable = [
'academy_id', 'website_menu_id', 'parent_id',
'label', 'label_en',
'link_type', 'website_page_id', 'url', 'anchor', 'route_name',
'icon', 'open_in_new_tab', 'is_visible', 'highlight', 'sort_order',
];
protected $casts = [
'open_in_new_tab' => 'boolean',
'is_visible' => 'boolean',
'highlight' => 'boolean',
'sort_order' => 'integer',
];
public function menu(): BelongsTo
{
return $this->belongsTo(WebsiteMenu::class, 'website_menu_id');
}
public function page(): BelongsTo
{
return $this->belongsTo(WebsitePage::class, 'website_page_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order');
}
public function childrenRecursive(): HasMany
{
return $this->children()->with('childrenRecursive');
}
public function scopeVisible($query)
{
return $query->where('is_visible', true);
}
public function localizedLabel(?string $locale = null): string
{
$locale ??= app()->getLocale();
return $locale === 'ar'
? ($this->label ?: $this->label_en ?: '')
: ($this->label_en ?: $this->label ?: '');
}
/**
* Resolves the item to an href.
*
* A missing or unsafe target yields null so the caller skips rendering the
* link entirely. External URLs pass through safe_url(), because this value
* is written straight into an href and menu items are editor-supplied.
*/
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,
'route' => $this->route_name && \Illuminate\Support\Facades\Route::has($this->route_name)
? route($this->route_name)
: null,
default => null,
};
}
/** A parent with children but no target is a dropdown trigger, not a dead link. */
public function isDropdownParent(): bool
{
return $this->link_type === 'none' && $this->children->isNotEmpty();
}
}
<?php
namespace App\Domain\Website\Models;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Domain\Shared\Traits\ManglesUniqueOnDelete;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class WebsitePage extends Model
{
use Auditable, BelongsToAcademy, HasUuid, ManglesUniqueOnDelete, SoftDeletes;
/** Frees the slug for reuse when a page is soft-deleted. */
protected array $uniqueFieldsToMangle = ['slug'];
protected array $mangleMaxLengths = ['slug' => 160];
protected $fillable = [
'academy_id',
'slug',
'title',
'title_en',
'meta_title',
'meta_title_en',
'meta_description',
'meta_description_en',
'og_image_path',
'noindex',
'layout',
'is_homepage',
'is_published',
'published_at',
'sort_order',
'settings',
'created_by',
];
protected $casts = [
'noindex' => 'boolean',
'is_homepage' => 'boolean',
'is_published' => 'boolean',
'published_at' => 'datetime',
'sort_order' => 'integer',
'settings' => 'array',
];
/** Top-level blocks only; nested children are loaded through the tree. */
public function blocks(): HasMany
{
return $this->hasMany(WebsiteBlock::class)
->whereNull('parent_id')
->orderBy('sort_order');
}
public function allBlocks(): HasMany
{
return $this->hasMany(WebsiteBlock::class);
}
public function scopePublished($query)
{
return $query->where('is_published', true);
}
public function localizedTitle(?string $locale = null): string
{
$locale ??= app()->getLocale();
return $locale === 'ar'
? ($this->title ?: $this->title_en ?: $this->slug)
: ($this->title_en ?: $this->title ?: $this->slug);
}
public function url(): string
{
return $this->is_homepage ? url('/') : url('/'.ltrim($this->slug, '/'));
}
}
<?php
namespace App\Domain\Website\Services;
use App\Domain\Shared\Models\Academy;
use App\Domain\Website\Models\WebsiteBlock;
use Illuminate\Support\Collection;
/**
* Supplies live ERP records to data-bound blocks.
*
* Keeping this out of the Blade partials means a block template never queries;
* it just receives `$items`. That also makes the builder preview and the public
* site render identically.
*/
class BlockDataResolver
{
public function __construct(
private readonly WebsiteDataService $data,
) {}
/** @return Collection<int,mixed> */
public function for(WebsiteBlock $block, array $context = []): Collection
{
$academy = $context['academy'] ?? null;
if (! $academy instanceof Academy) {
return collect();
}
$limit = (int) ($block->get('limit') ?: 0);
$items = match ($block->type) {
'data_branches' => $this->data->getBranches($academy),
'data_programs' => $this->data->getPrograms($academy),
'data_activities' => $this->data->getActivities($academy),
'data_news' => $this->data->getNews($academy, $limit ?: 6),
'data_testimonials' => $this->data->getTestimonials($academy, $limit ?: 10),
'gallery' => $block->get('source') === 'collection'
? $this->data->getGalleryImages($academy, $limit ?: 12)
: collect($block->get('images') ?: []),
'logo_strip' => $block->get('use_partner_data', true)
? $this->data->getPartners($academy)
: collect($block->get('logos') ?: []),
'accordion' => $block->get('use_faq_data')
? $this->data->getFaqs($academy)
: collect($block->get('items') ?: []),
default => collect($this->manualItems($block)),
};
$items = collect($items);
return $limit > 0 ? $items->take($limit) : $items;
}
/** Repeater-backed blocks simply expose their own rows as $items. */
private function manualItems(WebsiteBlock $block): array
{
foreach (['items', 'plans', 'logos', 'links', 'locations', 'products'] as $key) {
$rows = $block->get($key);
if (is_array($rows) && $rows !== []) {
return $rows;
}
}
return [];
}
}
<?php
namespace App\Domain\Website\Services;
use App\Domain\Website\Models\WebsiteBlock;
use App\Domain\Website\Models\WebsitePage;
use Illuminate\Support\Collection;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Turns a stored block tree into HTML.
*
* A block that fails to render is skipped with a logged warning rather than
* taking the whole public page down — a tenant's site staying up matters more
* than any single section.
*/
class BlockRenderer
{
public function __construct(
private readonly BlockDataResolver $data,
) {}
/** Renders every top-level block on a page. */
public function renderPage(WebsitePage $page, array $context = []): HtmlString
{
$blocks = $page->relationLoaded('blocks')
? $page->blocks
: $page->blocks()->with('childrenRecursive')->get();
return $this->renderMany($blocks, $context);
}
public function renderMany(Collection $blocks, array $context = []): HtmlString
{
$html = '';
foreach ($blocks as $block) {
$html .= $this->render($block, $context)->toHtml();
}
return new HtmlString($html);
}
public function render(WebsiteBlock $block, array $context = []): HtmlString
{
if (! $block->isRenderable()) {
return new HtmlString('');
}
$definition = $block->definition();
try {
$inner = view($definition->viewFor($block->variant), array_merge($context, [
'block' => $block,
'definition' => $definition,
'variant' => $block->variant,
'renderer' => $this,
'items' => $this->data->for($block, $context),
// Pristine page-level context, for container blocks to hand to
// their children without leaking their own local variables.
'ctx' => $context,
]))->render();
} catch (Throwable $e) {
Log::warning('Website block failed to render', [
'block_id' => $block->id,
'type' => $block->type,
'variant' => $block->variant,
'message' => $e->getMessage(),
]);
return new HtmlString(
app()->hasDebugModeEnabled()
? '<!-- block '.e($block->type).' failed: '.e($e->getMessage()).' -->'
: ''
);
}
// The shell owns background, padding, animation and visibility so that
// every block type gets them without repeating the markup.
return new HtmlString(view('website.blocks._shell', [
'block' => $block,
'definition' => $definition,
'content' => new HtmlString($inner),
])->render());
}
/** Renders the children of a container block, optionally limited to one slot. */
public function renderChildren(WebsiteBlock $block, string $slot = 'default', array $context = []): HtmlString
{
$children = $block->relationLoaded('children')
? $block->children->where('slot', $slot)
: $block->children()->where('slot', $slot)->get();
return $this->renderMany(collect($children), $context);
}
}
<?php
namespace App\Domain\Website\Services;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Website\Blocks\BlockRegistry;
use App\Domain\Website\Models\WebsiteBlock;
use App\Domain\Website\Models\WebsitePage;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class WebsiteBlockService
{
/** Guards against a pathological nesting depth in the builder UI. */
public const MAX_DEPTH = 5;
public function __construct(private readonly BlockRegistry $registry) {}
public function add(
WebsitePage $page,
string $type,
?WebsiteBlock $parent = null,
string $slot = 'default',
?int $position = null,
): WebsiteBlock {
$definition = $this->registry->resolve($type);
if ($parent) {
$this->assertCanNest($parent, $definition->key());
}
if ($definition->isSingleton() && $this->existsOnPage($page, $type)) {
throw new DomainException('لا يمكن إضافة أكثر من عنصر واحد من هذا النوع في الصفحة.');
}
return DB::transaction(function () use ($page, $definition, $parent, $slot, $position) {
$variants = array_keys($definition->variants());
$block = WebsiteBlock::create([
'website_page_id' => $page->id,
'parent_id' => $parent?->id,
'slot' => $slot,
'type' => $definition->key(),
'variant' => $variants[0] ?? 'default',
'sort_order' => $position ?? $this->nextSortOrder($page, $parent, $slot),
'is_enabled' => true,
'data' => $definition->defaultData(),
'style' => [],
]);
if ($position !== null) {
$this->normalizeSiblings($page, $parent, $slot);
}
return $block;
});
}
public function update(WebsiteBlock $block, array $attributes): WebsiteBlock
{
return DB::transaction(function () use ($block, $attributes) {
$payload = [];
if (array_key_exists('data', $attributes)) {
// Merge rather than replace: the builder saves one panel at a time.
$payload['data'] = array_replace_recursive(
$block->data ?? [],
$attributes['data'],
);
}
foreach (['variant', 'style', 'is_enabled', 'slot'] as $key) {
if (array_key_exists($key, $attributes)) {
$payload[$key] = $attributes[$key];
}
}
if (isset($payload['variant'])) {
$definition = $block->definition();
if ($definition && ! array_key_exists($payload['variant'], $definition->variants())) {
throw new DomainException('نمط العرض المختار غير متاح لهذا العنصر.');
}
}
$block->update($payload);
return $block->refresh();
});
}
/**
* Replaces a repeater's rows wholesale. Repeater rows are positional, so
* merging them would blend a deleted row's values into its successor.
*/
public function setRepeater(WebsiteBlock $block, string $field, array $rows): WebsiteBlock
{
return DB::transaction(function () use ($block, $field, $rows) {
$data = $block->data ?? [];
$data[$field] = array_values($rows);
$block->update(['data' => $data]);
return $block->refresh();
});
}
public function delete(WebsiteBlock $block): void
{
DB::transaction(fn () => $block->delete());
}
public function duplicate(WebsiteBlock $block): WebsiteBlock
{
return DB::transaction(function () use ($block) {
$copy = $this->copyTree($block, $block->website_page_id, $block->parent_id);
$copy->update(['sort_order' => $block->sort_order + 1]);
$this->normalizeSiblings(
$block->page ?? WebsitePage::find($block->website_page_id),
$block->parent,
$block->slot,
);
return $copy->refresh();
});
}
/** Recursively copies a block and its descendants. */
public function copyTree(WebsiteBlock $block, ?int $pageId, ?int $parentId): WebsiteBlock
{
$copy = $block->replicate(['uuid']);
$copy->uuid = (string) Str::uuid();
$copy->website_page_id = $pageId;
$copy->parent_id = $parentId;
$copy->save();
foreach ($block->children as $child) {
$this->copyTree($child, $pageId, $copy->id);
}
return $copy;
}
/** Moves a block to a new parent/slot and reorders its new siblings. */
public function move(WebsiteBlock $block, ?WebsiteBlock $parent, string $slot, int $position): WebsiteBlock
{
if ($parent) {
$this->assertCanNest($parent, $block->type);
if ($this->isDescendantOf($parent, $block)) {
throw new DomainException('لا يمكن نقل العنصر إلى داخل نفسه.');
}
}
return DB::transaction(function () use ($block, $parent, $slot, $position) {
$block->update([
'parent_id' => $parent?->id,
'slot' => $slot,
'sort_order' => $position,
]);
$page = WebsitePage::find($block->website_page_id);
$this->normalizeSiblings($page, $parent, $slot, $block->id, $position);
return $block->refresh();
});
}
public function reorder(array $orderedIds): void
{
DB::transaction(function () use ($orderedIds) {
foreach ($orderedIds as $i => $id) {
WebsiteBlock::where('id', $id)->update(['sort_order' => $i]);
}
});
}
private function existsOnPage(WebsitePage $page, string $type): bool
{
return WebsiteBlock::where('website_page_id', $page->id)
->where('type', $type)
->exists();
}
private function nextSortOrder(WebsitePage $page, ?WebsiteBlock $parent, string $slot): int
{
return (int) WebsiteBlock::where('website_page_id', $page->id)
->where('parent_id', $parent?->id)
->where('slot', $slot)
->max('sort_order') + 1;
}
/** Rewrites sibling sort_order to a dense 0..n sequence. */
private function normalizeSiblings(
?WebsitePage $page,
?WebsiteBlock $parent,
string $slot,
?int $movedId = null,
?int $movedPosition = null,
): void {
if (! $page) {
return;
}
$siblings = WebsiteBlock::where('website_page_id', $page->id)
->where('parent_id', $parent?->id)
->where('slot', $slot)
->orderBy('sort_order')
->orderBy('id')
->get();
if ($movedId !== null && $movedPosition !== null) {
$moved = $siblings->firstWhere('id', $movedId);
if ($moved) {
$siblings = $siblings->reject(fn ($b) => $b->id === $movedId)->values();
$siblings->splice(min($movedPosition, $siblings->count()), 0, [$moved]);
}
}
foreach ($siblings->values() as $i => $sibling) {
if ($sibling->sort_order !== $i) {
$sibling->updateQuietly(['sort_order' => $i]);
}
}
}
private function assertCanNest(WebsiteBlock $parent, string $childType): void
{
$definition = $parent->definition();
if (! $definition || ! $definition->allowsChildren()) {
throw new DomainException('هذا العنصر لا يقبل عناصر بداخله.');
}
$allowed = $definition->allowedChildTypes();
if ($allowed !== null && ! in_array($childType, $allowed, true)) {
throw new DomainException('نوع العنصر غير مسموح داخل هذه الحاوية.');
}
if ($this->depthOf($parent) + 1 >= self::MAX_DEPTH) {
throw new DomainException('تم بلوغ الحد الأقصى لتداخل العناصر.');
}
}
private function depthOf(WebsiteBlock $block): int
{
$depth = 0;
$cursor = $block;
while ($cursor->parent_id && $depth < self::MAX_DEPTH + 1) {
$cursor = $cursor->parent;
if (! $cursor) {
break;
}
$depth++;
}
return $depth;
}
private function isDescendantOf(WebsiteBlock $candidate, WebsiteBlock $ancestor): bool
{
if ($candidate->id === $ancestor->id) {
return true;
}
$cursor = $candidate;
$guard = 0;
while ($cursor->parent_id && $guard++ < self::MAX_DEPTH + 1) {
if ($cursor->parent_id === $ancestor->id) {
return true;
}
$cursor = $cursor->parent;
if (! $cursor) {
break;
}
}
return false;
}
}
<?php
namespace App\Domain\Website\Services;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Website\Blocks\BlockField;
use App\Domain\Website\Blocks\BlockRegistry;
use App\Domain\Website\Blocks\BlockType;
use App\Domain\Website\Blocks\FieldType;
use App\Domain\Website\Models\WebsiteBlock;
use App\Domain\Website\Models\WebsitePage;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* Import/export a whole site as JSON.
*
* Lets a finished design be snapshotted and re-applied to another academy, so
* building a site once for one client makes it a reusable starting point for
* the next. Import is explicit (artisan) and never runs during automatic
* seeding, because every tenant boots from the same image.
*/
class WebsiteBlueprintService
{
public function __construct(
private readonly BlockRegistry $registry,
private readonly WebsitePageService $pages,
) {}
public const VERSION = 1;
/** @return array<string,mixed> */
public function export(): array
{
return [
'version' => self::VERSION,
'pages' => WebsitePage::with('blocks.childrenRecursive')
->orderBy('sort_order')
->get()
->map(fn (WebsitePage $page) => [
'slug' => $page->slug,
'title' => $page->title,
'title_en' => $page->title_en,
'meta_description' => $page->meta_description,
'meta_description_en' => $page->meta_description_en,
'layout' => $page->layout,
'is_homepage' => $page->is_homepage,
'is_published' => $page->is_published,
'sort_order' => $page->sort_order,
'blocks' => $page->blocks->map(fn ($b) => $this->exportBlock($b))->all(),
])->all(),
];
}
private function exportBlock(WebsiteBlock $block): array
{
return [
'type' => $block->type,
'variant' => $block->variant,
'slot' => $block->slot,
'is_enabled' => $block->is_enabled,
'data' => $block->data ?? [],
'style' => $block->style ?? [],
'children' => $block->children->map(fn ($c) => $this->exportBlock($c))->all(),
];
}
/**
* Imports a blueprint for the current academy.
*
* @param bool $replace drop existing pages with the same slug first
* @return array{pages:int, blocks:int, skipped:array<string>}
*/
public function import(array $blueprint, User $actor, bool $replace = false): array
{
if (($blueprint['version'] ?? null) !== self::VERSION) {
throw new DomainException('إصدار ملف التصميم غير مدعوم.');
}
$stats = ['pages' => 0, 'blocks' => 0, 'skipped' => []];
DB::transaction(function () use ($blueprint, $actor, $replace, &$stats) {
foreach ($blueprint['pages'] ?? [] as $pageData) {
$slug = $pageData['slug'] ?? null;
if (! $slug) {
continue;
}
$existing = WebsitePage::where('slug', $slug)->first();
if ($existing && ! $replace) {
$stats['skipped'][] = $slug;
continue;
}
if ($existing) {
WebsiteBlock::where('website_page_id', $existing->id)->delete();
$existing->forceDelete();
}
$page = $this->pages->create([
'slug' => $slug,
'title' => $pageData['title'] ?? null,
'title_en' => $pageData['title_en'] ?? null,
'meta_description' => $pageData['meta_description'] ?? null,
'meta_description_en' => $pageData['meta_description_en'] ?? null,
'layout' => $pageData['layout'] ?? 'default',
'is_published' => $pageData['is_published'] ?? false,
'sort_order' => $pageData['sort_order'] ?? 0,
], $actor);
foreach ($pageData['blocks'] ?? [] as $i => $blockData) {
$stats['blocks'] += $this->importBlock($blockData, $page->id, null, $i, $stats);
}
if ($pageData['is_homepage'] ?? false) {
$this->pages->makeHomepage($page);
}
$stats['pages']++;
}
});
return $stats;
}
/**
* Strips unsafe link targets from imported content.
*
* A blueprint is a file, so it bypasses the form validation that normally
* constrains link fields. Without this an imported design could carry a
* `javascript:` URL straight into a rendered href.
*/
private function sanitizeData(BlockType $definition, array $data): array
{
foreach ($definition->fields() as $field) {
if (! array_key_exists($field->key, $data)) {
continue;
}
if ($field->type === FieldType::Link) {
$data[$field->key] = safe_url(is_string($data[$field->key]) ? $data[$field->key] : null);
continue;
}
if ($field->type === FieldType::Repeater && is_array($data[$field->key])) {
foreach ($data[$field->key] as $i => $row) {
if (! is_array($row)) {
continue;
}
foreach ($field->fields as $sub) {
if ($sub->type === FieldType::Link && array_key_exists($sub->key, $row)) {
$data[$field->key][$i][$sub->key] = safe_url(
is_string($row[$sub->key]) ? $row[$sub->key] : null,
);
}
}
}
}
}
return $data;
}
private function importBlock(array $data, int $pageId, ?int $parentId, int $order, array &$stats): int
{
$type = $data['type'] ?? null;
// An unknown type means the blueprint came from a build with a block we
// do not have. Skip it rather than aborting the whole import.
if (! $type || ! $this->registry->has($type)) {
$stats['skipped'][] = "block:{$type}";
return 0;
}
$definition = $this->registry->resolve($type);
$variants = array_keys($definition->variants());
$variant = $data['variant'] ?? null;
$block = WebsiteBlock::create([
'website_page_id' => $pageId,
'parent_id' => $parentId,
'slot' => $data['slot'] ?? 'default',
'type' => $type,
'variant' => in_array($variant, $variants, true) ? $variant : ($variants[0] ?? 'default'),
'sort_order' => $order,
'is_enabled' => $data['is_enabled'] ?? true,
'data' => $this->sanitizeData($definition, array_replace_recursive(
$definition->defaultData(),
$data['data'] ?? [],
)),
'style' => $data['style'] ?? [],
]);
$count = 1;
foreach ($data['children'] ?? [] as $i => $child) {
$count += $this->importBlock($child, $pageId, $block->id, $i, $stats);
}
return $count;
}
}
<?php
namespace App\Domain\Website\Services;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Website\Models\WebsiteMenu;
use App\Domain\Website\Models\WebsiteMenuItem;
use Illuminate\Support\Facades\DB;
class WebsiteMenuService
{
public const MAX_DEPTH = 3;
public const KEYS = ['primary', 'footer', 'footer_secondary', 'mobile', 'utility'];
public function getOrCreate(string $key): WebsiteMenu
{
if (! in_array($key, self::KEYS, true)) {
throw new DomainException('نوع القائمة غير معروف.');
}
return WebsiteMenu::firstOrCreate(
['key' => $key],
['name' => WebsiteMenu::labelFor($key)],
);
}
public function addItem(WebsiteMenu $menu, array $data, ?WebsiteMenuItem $parent = null): WebsiteMenuItem
{
if ($parent && $this->depthOf($parent) + 1 >= self::MAX_DEPTH) {
throw new DomainException('تم بلوغ الحد الأقصى لتداخل القوائم.');
}
return DB::transaction(fn () => WebsiteMenuItem::create([
'website_menu_id' => $menu->id,
'parent_id' => $parent?->id,
'label' => $data['label'] ?? null,
'label_en' => $data['label_en'] ?? null,
'link_type' => $data['link_type'] ?? 'page',
'website_page_id' => $data['website_page_id'] ?? null,
'url' => $data['url'] ?? null,
'anchor' => $data['anchor'] ?? null,
'route_name' => $data['route_name'] ?? 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' => (int) (WebsiteMenuItem::where('website_menu_id', $menu->id)
->where('parent_id', $parent?->id)->max('sort_order') + 1),
]));
}
public function updateItem(WebsiteMenuItem $item, array $data): WebsiteMenuItem
{
return DB::transaction(function () use ($item, $data) {
// Clear the target fields that no longer apply, so a type change
// cannot leave a stale url behind that later resurfaces.
$type = $data['link_type'] ?? $item->link_type;
$data['website_page_id'] = $type === 'page' ? ($data['website_page_id'] ?? null) : null;
$data['url'] = $type === 'url' ? ($data['url'] ?? null) : null;
$data['anchor'] = $type === 'anchor' ? ($data['anchor'] ?? null) : null;
$data['route_name'] = $type === 'route' ? ($data['route_name'] ?? null) : null;
$item->update($data);
return $item->refresh();
});
}
public function deleteItem(WebsiteMenuItem $item): void
{
DB::transaction(fn () => $item->delete());
}
public function move(WebsiteMenuItem $item, int $direction): void
{
$siblings = WebsiteMenuItem::where('website_menu_id', $item->website_menu_id)
->where('parent_id', $item->parent_id)
->orderBy('sort_order')->orderBy('id')
->pluck('id')->all();
$i = array_search($item->id, $siblings, true);
$target = $i + $direction;
if ($i === false || ! isset($siblings[$target])) {
return;
}
[$siblings[$i], $siblings[$target]] = [$siblings[$target], $siblings[$i]];
$this->reorder($siblings);
}
public function reorder(array $orderedIds): void
{
DB::transaction(function () use ($orderedIds) {
foreach ($orderedIds as $i => $id) {
WebsiteMenuItem::where('id', $id)->update(['sort_order' => $i]);
}
});
}
/** Visible tree for public rendering. */
public function tree(string $key)
{
return $this->getOrCreate($key)
->items()
->visible()
->with(['childrenRecursive', 'page'])
->get();
}
private function depthOf(WebsiteMenuItem $item): int
{
$depth = 0;
$cursor = $item;
while ($cursor->parent_id && $depth < self::MAX_DEPTH + 1) {
$cursor = $cursor->parent()->first();
if (! $cursor) {
break;
}
$depth++;
}
return $depth;
}
}
<?php
namespace App\Domain\Website\Services;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Website\Models\WebsiteBlock;
use App\Domain\Website\Models\WebsitePage;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class WebsitePageService
{
/** Slugs that would collide with real application routes. */
public const RESERVED_SLUGS = [
'admin', 'login', 'logout', 'register', 'password', 'api', 'public',
'website', 'parent', 'trainer', 'receptionist', 'dashboard', 'storage',
'livewire', 'up', 'health',
];
public function create(array $data, User $actor): WebsitePage
{
return DB::transaction(function () use ($data, $actor) {
$slug = $this->uniqueSlug($data['slug'] ?? $data['title'] ?? 'page');
$page = WebsitePage::create([
'slug' => $slug,
'title' => $data['title'] ?? null,
'title_en' => $data['title_en'] ?? null,
'meta_title' => $data['meta_title'] ?? null,
'meta_title_en' => $data['meta_title_en'] ?? null,
'meta_description' => $data['meta_description'] ?? null,
'meta_description_en' => $data['meta_description_en'] ?? null,
'layout' => $data['layout'] ?? 'default',
'is_homepage' => false,
'is_published' => $data['is_published'] ?? false,
'published_at' => ($data['is_published'] ?? false) ? now() : null,
'sort_order' => $data['sort_order'] ?? ((WebsitePage::max('sort_order') ?? 0) + 1),
'settings' => $data['settings'] ?? [],
'created_by' => $actor->id,
]);
if ($data['is_homepage'] ?? false) {
$this->makeHomepage($page);
}
return $page;
});
}
public function update(WebsitePage $page, array $data): WebsitePage
{
return DB::transaction(function () use ($page, $data) {
if (array_key_exists('slug', $data) && $data['slug'] !== $page->slug) {
$data['slug'] = $this->uniqueSlug($data['slug'], $page->id);
}
if (array_key_exists('is_published', $data)) {
$data['published_at'] = $data['is_published']
? ($page->published_at ?? now())
: null;
}
$wantsHomepage = (bool) ($data['is_homepage'] ?? false);
unset($data['is_homepage']);
$page->update($data);
if ($wantsHomepage && ! $page->is_homepage) {
$this->makeHomepage($page);
}
return $page->refresh();
});
}
/**
* Promotes a page to homepage. Demoting the previous one first is required —
* a partial unique index enforces exactly one homepage per academy.
*/
public function makeHomepage(WebsitePage $page): WebsitePage
{
return DB::transaction(function () use ($page) {
WebsitePage::where('is_homepage', true)
->where('id', '!=', $page->id)
->update(['is_homepage' => false]);
$page->update(['is_homepage' => true, 'is_published' => true]);
return $page->refresh();
});
}
/** Deep-copies a page and its entire block tree. */
public function duplicate(WebsitePage $page, User $actor): WebsitePage
{
return DB::transaction(function () use ($page, $actor) {
$copy = $page->replicate(['uuid', 'is_homepage', 'is_published', 'published_at']);
$copy->uuid = (string) Str::uuid();
$copy->slug = $this->uniqueSlug($page->slug.'-copy');
$copy->title = $page->title ? $page->title.' (نسخة)' : null;
$copy->title_en = $page->title_en ? $page->title_en.' (copy)' : null;
$copy->is_homepage = false;
$copy->is_published = false;
$copy->published_at = null;
$copy->created_by = $actor->id;
$copy->save();
foreach ($page->blocks as $block) {
app(WebsiteBlockService::class)->copyTree($block, $copy->id, null);
}
return $copy;
});
}
public function delete(WebsitePage $page): void
{
if ($page->is_homepage) {
throw new DomainException('لا يمكن حذف الصفحة الرئيسية. عيّن صفحة أخرى كرئيسية أولًا.');
}
DB::transaction(function () use ($page) {
WebsiteBlock::where('website_page_id', $page->id)->delete();
$page->delete();
});
}
public function reorder(array $orderedIds): void
{
DB::transaction(function () use ($orderedIds) {
foreach ($orderedIds as $i => $id) {
WebsitePage::where('id', $id)->update(['sort_order' => $i]);
}
});
}
/**
* Produces a slug that is URL-safe, unique for the academy, and not a
* reserved application route. Arabic titles slugify to empty, so a
* deterministic fallback is required rather than an empty slug.
*/
public function uniqueSlug(string $source, ?int $ignoreId = null): string
{
$base = Str::slug($source);
if ($base === '') {
$base = Str::slug(Str::ascii($source)) ?: 'page-'.Str::lower(Str::random(6));
}
if (in_array($base, self::RESERVED_SLUGS, true)) {
$base .= '-page';
}
$slug = $base;
$n = 2;
while (WebsitePage::withTrashed()
->where('slug', $slug)
->when($ignoreId, fn ($q) => $q->where('id', '!=', $ignoreId))
->exists()) {
$slug = "{$base}-{$n}";
$n++;
}
return $slug;
}
}
<?php
use Illuminate\Support\Str;
if (! function_exists('clean_html')) {
/**
* Sanitises rich-text and pasted markup before it reaches a public page.
*
* Builder content is authored by academy staff, not developers, and the
* custom-HTML block accepts arbitrary paste. Without this, one pasted
* snippet could run script on every visitor of a tenant's site.
* Allow-list based: anything not explicitly permitted is dropped.
*/
function clean_html(?string $html): string
{
if (blank($html)) {
return '';
}
$allowedTags = [
'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'span', 'div',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'blockquote', 'hr',
'a', 'img', 'figure', 'figcaption',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'sup', 'sub', 'small', 'code', 'pre',
];
$allowedAttributes = [
'a' => ['href', 'title', 'target', 'rel'],
'img' => ['src', 'alt', 'width', 'height', 'loading'],
'td' => ['colspan', 'rowspan'],
'th' => ['colspan', 'rowspan', 'scope'],
'*' => ['class', 'dir', 'id'],
];
$previous = libxml_use_internal_errors(true);
$doc = new DOMDocument('1.0', 'UTF-8');
$doc->loadHTML(
'<?xml encoding="UTF-8"><div id="ec-clean-root">'.$html.'</div>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NONET,
);
libxml_clear_errors();
libxml_use_internal_errors($previous);
$root = $doc->getElementById('ec-clean-root');
if (! $root) {
return e($html);
}
$xpath = new DOMXPath($doc);
// Strip disallowed elements, keeping their text where sensible.
foreach (iterator_to_array($xpath->query('//*', $root)) as $node) {
if (! $node instanceof DOMElement || $node === $root) {
continue;
}
$tag = strtolower($node->nodeName);
if (! in_array($tag, $allowedTags, true)) {
// script/style contents are discarded entirely; other unknown
// wrappers are unwrapped so their text survives.
if (in_array($tag, ['script', 'style', 'iframe', 'object', 'embed', 'form'], true)) {
$node->parentNode?->removeChild($node);
} else {
while ($node->firstChild) {
$node->parentNode?->insertBefore($node->firstChild, $node);
}
$node->parentNode?->removeChild($node);
}
continue;
}
$permitted = array_merge($allowedAttributes['*'], $allowedAttributes[$tag] ?? []);
foreach (iterator_to_array($node->attributes ?? []) as $attr) {
$name = strtolower($attr->nodeName);
$value = trim($attr->nodeValue ?? '');
// Drop every event handler and anything not allow-listed.
if (str_starts_with($name, 'on') || ! in_array($name, $permitted, true)) {
$node->removeAttribute($attr->nodeName);
continue;
}
// Block javascript:, data: and other executable URL schemes.
if (in_array($name, ['href', 'src'], true)) {
$scheme = Str::of($value)->lower()->replace([' ', "\t", "\n", "\0"], '')->value();
$safe = $scheme === ''
|| str_starts_with($scheme, 'http://')
|| str_starts_with($scheme, 'https://')
|| str_starts_with($scheme, 'mailto:')
|| str_starts_with($scheme, 'tel:')
|| str_starts_with($scheme, '/')
|| str_starts_with($scheme, '#')
|| ($name === 'src' && str_starts_with($scheme, 'data:image/'));
if (! $safe) {
$node->removeAttribute($attr->nodeName);
}
}
}
// Any link leaving the site opens safely.
if ($tag === 'a' && $node->getAttribute('target') === '_blank') {
$node->setAttribute('rel', 'noopener noreferrer');
}
}
$out = '';
foreach ($root->childNodes as $child) {
$out .= $doc->saveHTML($child);
}
return $out;
}
}
if (! function_exists('website_video_embed_url')) {
/** Builds a player URL with playback flags for YouTube/Vimeo. */
function website_video_embed_url(?string $url, array $params = []): ?string
{
if (blank($url)) {
return null;
}
$query = array_filter($params, fn ($v) => $v !== null && $v !== '' && $v !== 0);
if (preg_match('~(?:youtube\.com/(?:watch\?v=|embed/|shorts/)|youtu\.be/)([\w-]{6,})~i', $url, $m)) {
if (! empty($params['loop'])) {
$query['playlist'] = $m[1];
}
return 'https://www.youtube-nocookie.com/embed/'.$m[1]
.($query ? '?'.http_build_query($query) : '');
}
if (preg_match('~vimeo\.com/(?:video/)?(\d+)~i', $url, $m)) {
$vimeo = array_filter([
'autoplay' => $params['autoplay'] ?? null,
'muted' => $params['mute'] ?? null,
'loop' => $params['loop'] ?? null,
]);
return 'https://player.vimeo.com/video/'.$m[1]
.($vimeo ? '?'.http_build_query($vimeo) : '');
}
return null;
}
}
if (! function_exists('website_embed_url')) {
/**
* Resolves an embed URL for an allow-listed provider.
* Returning null for anything unrecognised keeps arbitrary third-party
* iframes off tenant sites.
*/
function website_embed_url(?string $provider, ?string $url): ?string
{
if (blank($provider) || blank($url)) {
return null;
}
return match ($provider) {
'youtube', 'vimeo' => website_video_embed_url($url),
'google_maps' => 'https://www.google.com/maps?q='.urlencode($url).'&output=embed',
'google_form' => str_contains($url, 'docs.google.com/forms') ? $url : null,
'instagram' => preg_match('~instagram\.com/(p|reel)/([\w-]+)~i', $url, $m)
? "https://www.instagram.com/{$m[1]}/{$m[2]}/embed"
: null,
'facebook' => 'https://www.facebook.com/plugins/page.php?href='.urlencode($url),
default => null,
};
}
}
if (! function_exists('safe_url')) {
/**
* Returns a link target only when its scheme is safe to place in an href.
*
* Builder content is authored by staff, and menu items, button links and
* blueprint imports all feed straight into href attributes. Without this a
* stored `javascript:` URL would execute for every visitor of a tenant's
* public site. Returns null for anything not allow-listed so callers can
* skip rendering the link entirely rather than emitting a dead or unsafe one.
*/
function safe_url(?string $url): ?string
{
if (blank($url)) {
return null;
}
$raw = trim($url);
// Strip characters browsers ignore when resolving a scheme, so that
// "java\tscript:" and "java&#115;cript:" cannot slip past the check.
$probe = html_entity_decode($raw, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$probe = preg_replace('/[\x00-\x20\x7F]+/u', '', (string) $probe) ?? '';
$probe = mb_strtolower($probe);
// Protocol-relative URLs (//evil.test) inherit the page scheme and are
// an easy way to leave the site unnoticed — treat them as absolute.
if (str_starts_with($probe, '//')) {
return null;
}
// Site-relative paths and fragments are always fine.
if (str_starts_with($probe, '/') || str_starts_with($probe, '#') || str_starts_with($probe, '?')) {
return $raw;
}
if (preg_match('/^([a-z][a-z0-9+.\-]*):/', $probe, $m)) {
return in_array($m[1], ['http', 'https', 'mailto', 'tel', 'whatsapp'], true) ? $raw : null;
}
// No scheme at all — a bare path or domain fragment.
return $raw;
}
}
<?php
namespace App\Http\Controllers;
use App\Domain\Shared\Models\Academy;
use App\Domain\Website\Models\WebsitePage;
use App\Domain\Website\Services\WebsiteSettingService;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Renders builder pages (website_pages + website_blocks).
*
* Runs after every other web route so a page slug can never shadow a real
* application route; WebsitePageService additionally refuses reserved slugs.
*/
class WebsitePageController extends Controller
{
public function __construct(
private readonly WebsiteSettingService $settings,
) {}
/**
* Fallback entry point. Route::fallback passes no route parameters, so the
* slug is taken from the request path rather than a bound argument.
*/
public function fallback(Request $request)
{
$slug = trim($request->path(), '/');
if ($slug === '' || $slug === '/') {
throw new NotFoundHttpException;
}
return $this->show($request, $slug);
}
public function show(Request $request, string $slug)
{
$academy = $this->academy();
$page = WebsitePage::query()
->where('slug', $slug)
->when(! $this->canPreview($request), fn ($q) => $q->where('is_published', true))
->first();
if (! $page) {
throw new NotFoundHttpException;
}
return $this->renderPage($academy, $page);
}
/** Homepage, when the academy has promoted a builder page to be it. */
public function home(Request $request)
{
$academy = $this->academy();
$page = WebsitePage::query()
->where('is_homepage', true)
->when(! $this->canPreview($request), fn ($q) => $q->where('is_published', true))
->first();
if (! $page) {
throw new NotFoundHttpException;
}
return $this->renderPage($academy, $page);
}
/**
* Entry point for "/". Serves a builder homepage when one exists, otherwise
* hands off to the legacy section renderer so already-live tenants that have
* not migrated keep their site exactly as it is.
*/
public function homeOrLegacy(Request $request, PublicWebsiteController $legacy)
{
$hasBuilderHome = WebsitePage::query()
->where('is_homepage', true)
->where('is_published', true)
->exists();
return $hasBuilderHome ? $this->home($request) : $legacy->home();
}
/** Draft preview for staff who can manage the site. */
public function preview(Request $request, WebsitePage $page)
{
abort_unless($request->user()?->can('settings.manage'), 403);
return $this->renderPage($this->academy(), $page, preview: true);
}
private function renderPage(Academy $academy, WebsitePage $page, bool $preview = false)
{
app()->instance('current_academy', $academy);
$page->load(['blocks.childrenRecursive']);
return view('website.page', [
'academy' => $academy,
'settings' => $this->settings->getOrCreate($academy),
'page' => $page,
'preview' => $preview,
]);
}
private function academy(): Academy
{
$academy = app()->bound('current_academy')
? app('current_academy')
: Academy::first();
abort_unless($academy, 404);
return $academy;
}
private function canPreview(Request $request): bool
{
return (bool) $request->user()?->can('settings.manage');
}
}
...@@ -53,7 +53,7 @@ protected function rules(): array ...@@ -53,7 +53,7 @@ protected function rules(): array
'label_en' => 'nullable|string|max:255', 'label_en' => 'nullable|string|max:255',
'link_type' => 'required|in:page,url,anchor,route,none', 'link_type' => 'required|in:page,url,anchor,route,none',
'website_page_id' => 'nullable|integer|exists:website_pages,id', 'website_page_id' => 'nullable|integer|exists:website_pages,id',
'url' => 'nullable|string|max:500', 'url' => ['nullable', 'string', 'max:500', 'regex:/^\s*(https?:\/\/|mailto:|tel:|\/(?!\/)|#|\?)/i'],
'anchor' => 'nullable|string|max:120', 'anchor' => 'nullable|string|max:120',
'icon' => 'nullable|string|max:60', 'icon' => 'nullable|string|max:60',
]; ];
...@@ -67,6 +67,7 @@ protected function messages(): array ...@@ -67,6 +67,7 @@ protected function messages(): array
'link_type.in' => __('نوع الرابط غير صالح'), 'link_type.in' => __('نوع الرابط غير صالح'),
'website_page_id.exists' => __('الصفحة المختارة غير موجودة'), 'website_page_id.exists' => __('الصفحة المختارة غير موجودة'),
'url.max' => __('الرابط طويل جدًا'), 'url.max' => __('الرابط طويل جدًا'),
'url.regex' => __('الرابط غير صالح. استخدم رابطًا يبدأ بـ https:// أو مسارًا داخليًا يبدأ بـ /'),
]; ];
} }
......
<?php
namespace App\Providers;
use App\Domain\Website\Blocks\BlockRegistry;
use App\Domain\Website\Blocks\Types;
use Illuminate\Support\ServiceProvider;
/**
* Registers every block the website builder offers.
*
* To add a capability to the builder: create a class in Blocks/Types and add it
* to the list below. No migration, no enum change, no CHECK constraint.
*/
class WebsiteServiceProvider extends ServiceProvider
{
/** @var array<class-string> */
public const BLOCKS = [
// Layout
Types\SectionBlock::class,
Types\ColumnsBlock::class,
Types\SpacerBlock::class,
Types\DividerBlock::class,
// Hero
Types\HeroBlock::class,
// Content
Types\TextImageBlock::class,
Types\CardGridBlock::class,
Types\RichTextBlock::class,
Types\StatsBlock::class,
Types\AccordionBlock::class,
// Media
Types\GalleryBlock::class,
Types\VideoBlock::class,
// People
Types\ProfileCardBlock::class,
Types\TrainersBlock::class,
Types\TestimonialsBlock::class,
// Live ERP data
Types\BranchesBlock::class,
Types\ProgramsBlock::class,
Types\ActivitiesBlock::class,
Types\ScheduleBlock::class,
Types\NewsBlock::class,
Types\EventsBlock::class,
// Commerce
Types\PricingBlock::class,
// Social & contact
Types\LogoStripBlock::class,
Types\InfoCardsBlock::class,
Types\MapBlock::class,
Types\SocialLinksBlock::class,
// Action
Types\CtaBlock::class,
Types\ContactFormBlock::class,
Types\AppDownloadBlock::class,
// Advanced
Types\CustomHtmlBlock::class,
Types\EmbedBlock::class,
];
public function register(): void
{
$this->app->singleton(BlockRegistry::class, function () {
return (new BlockRegistry)->registerMany(self::BLOCKS);
});
}
}
...@@ -5,4 +5,5 @@ ...@@ -5,4 +5,5 @@
return [ return [
AppServiceProvider::class, AppServiceProvider::class,
App\Providers\EventServiceProvider::class, App\Providers\EventServiceProvider::class,
App\Providers\WebsiteServiceProvider::class,
]; ];
...@@ -26,7 +26,8 @@ ...@@ -26,7 +26,8 @@
"files": [ "files": [
"app/Helpers/money.php", "app/Helpers/money.php",
"app/Helpers/whatsapp.php", "app/Helpers/whatsapp.php",
"app/Helpers/video.php" "app/Helpers/video.php",
"app/Helpers/website.php"
], ],
"psr-4": { "psr-4": {
"App\\": "app/", "App\\": "app/",
......
{
"version": 1,
"pages": [
{
"slug": "home",
"title": "الرئيسية",
"title_en": "Home",
"layout": "full_width",
"is_homepage": true,
"is_published": true,
"sort_order": 0,
"blocks": [
{
"type": "hero",
"variant": "product_showcase",
"slot": "default",
"is_enabled": true,
"data": {
"eyebrow": {
"ar": "مرحبًا بك في",
"en": "Welcome to"
},
"title": {
"ar": "اسم الأكاديمية",
"en": "Academy name"
},
"subtitle": {
"ar": "الطريق للبطولة",
"en": "The road to championship"
},
"height": "three_quarter",
"align": "center",
"show_scroll_hint": true,
"products": [
{
"name": {
"ar": "أزرق",
"en": "Blue"
},
"swatch": "#4c94ff",
"front": "",
"back": ""
},
{
"name": {
"ar": "أحمر",
"en": "Red"
},
"swatch": "#e03131",
"front": "",
"back": ""
},
{
"name": {
"ar": "أخضر",
"en": "Green"
},
"swatch": "#2f9e44",
"front": "",
"back": ""
}
],
"buttons": [
{
"label": {
"ar": "سجّل الآن",
"en": "Join now"
},
"url": "/contact-us",
"style": "primary"
}
]
},
"style": {
"bg_type": "image",
"bg_overlay_opacity": 55,
"padding_y": "none",
"animation": "none"
},
"children": []
},
{
"type": "text_image",
"variant": "image_end",
"slot": "default",
"is_enabled": true,
"data": {
"eyebrow": {
"ar": "من نحن",
"en": "About us"
},
"title": {
"ar": "من نحن",
"en": "Who we are"
},
"body": {
"ar": "<p>نبذة عن الأكاديمية.</p>",
"en": "<p>About the academy.</p>"
},
"image": "",
"image_ratio": "landscape"
},
"style": {
"animation": "fade-up",
"padding_y": "lg"
},
"children": []
},
{
"type": "profile_card",
"variant": "split",
"slot": "default",
"is_enabled": true,
"data": {
"section_title": {
"ar": "رئيس مجلس الإدارة",
"en": "Chairman of the board"
},
"name": {
"ar": "الاسم",
"en": "Name"
},
"role": {
"ar": "رئيس مجلس الإدارة والمؤسس",
"en": "Chairman & Founder"
},
"badge": {
"ar": "منذ 2008",
"en": "Since 2008"
},
"photo": "",
"details": [
{
"icon": "calendar",
"label": {
"ar": "تاريخ الميلاد",
"en": "Date of birth"
},
"value": {
"ar": "—",
"en": "—"
}
},
{
"icon": "academic-cap",
"label": {
"ar": "المؤهل",
"en": "Education"
},
"value": {
"ar": "—",
"en": "—"
}
}
],
"achievements": [
{
"text": {
"ar": "إنجاز",
"en": "Achievement"
}
}
]
},
"style": {
"animation": "fade-up",
"padding_y": "lg"
},
"children": []
},
{
"type": "video",
"variant": "with_quote",
"slot": "default",
"is_enabled": true,
"data": {
"eyebrow": {
"ar": "الرؤية",
"en": "The vision"
},
"title": {
"ar": "تجربتنا",
"en": "Our experience"
},
"url": "",
"ratio": "16:9",
"muted": true,
"quote": {
"ar": "اقتباس قصير.",
"en": "A short quote."
}
},
"style": {
"animation": "fade-up",
"padding_y": "lg"
},
"children": []
},
{
"type": "data_branches",
"variant": "cards",
"slot": "default",
"is_enabled": true,
"data": {
"title": {
"ar": "فروعنا",
"en": "Our branches"
},
"subtitle": {
"ar": "مواقع تخدمك بشكل أفضل",
"en": "Locations to serve you better"
},
"columns": "3",
"show_search": true,
"show_location": true,
"show_image": true
},
"style": {
"animation": "fade-up",
"padding_y": "lg",
"animation_stagger": 90
},
"children": []
},
{
"type": "data_schedule",
"variant": "cards",
"slot": "default",
"is_enabled": true,
"data": {
"title": {
"ar": "جدول المواعيد",
"en": "Training schedule"
},
"group_image_enabled": true,
"enable_lightbox": true,
"columns": "4"
},
"style": {
"animation": "fade-up",
"padding_y": "lg",
"animation_stagger": 90
},
"children": []
},
{
"type": "logo_strip",
"variant": "marquee",
"slot": "default",
"is_enabled": true,
"data": {
"title": {
"ar": "شركاؤنا",
"en": "Our partners"
},
"subtitle": {
"ar": "نتعاون مع الأفضل",
"en": "We work with the best"
},
"use_partner_data": true,
"grayscale": true,
"speed": "normal"
},
"style": {
"animation": "fade-up",
"padding_y": "lg"
},
"children": []
},
{
"type": "cta",
"variant": "gradient",
"slot": "default",
"is_enabled": true,
"data": {
"title": {
"ar": "هل أنت مستعد للبدء؟",
"en": "Ready to get started?"
},
"description": {
"ar": "انضم إلينا اليوم.",
"en": "Join us today."
},
"align": "center",
"buttons": [
{
"label": {
"ar": "ابدأ رحلتك",
"en": "Start your journey"
},
"url": "/contact-us",
"style": "white"
}
]
},
"style": {
"animation": "fade-up",
"padding_y": "lg"
},
"children": []
}
]
},
{
"slug": "about-us",
"title": "من نحن",
"title_en": "About Us",
"layout": "default",
"is_homepage": false,
"is_published": true,
"sort_order": 1,
"blocks": [
{
"type": "section",
"variant": "narrow",
"slot": "default",
"is_enabled": true,
"data": {
"title": {
"ar": "من نحن",
"en": "About us"
}
},
"style": {
"padding_y": "lg",
"animation": "fade-up"
},
"children": [
{
"type": "rich_text",
"variant": "prose_narrow",
"slot": "default",
"is_enabled": true,
"data": {
"body": {
"ar": "<p>نبذة.</p>",
"en": "<p>About.</p>"
}
},
"style": {},
"children": []
}
]
},
{
"type": "card_grid",
"variant": "icon_top",
"slot": "default",
"is_enabled": true,
"data": {
"title": {
"ar": "قيمنا",
"en": "Our values"
},
"columns": "3",
"items": [
{
"icon": "eye",
"title": {
"ar": "الرؤية",
"en": "Vision"
},
"body": {
"ar": "<p>—</p>",
"en": "<p>—</p>"
}
},
{
"icon": "flag",
"title": {
"ar": "الرسالة",
"en": "Mission"
},
"body": {
"ar": "<p>—</p>",
"en": "<p>—</p>"
}
},
{
"icon": "star",
"title": {
"ar": "ما يميزنا",
"en": "Our edge"
},
"body": {
"ar": "<p>—</p>",
"en": "<p>—</p>"
}
}
]
},
"style": {
"animation": "fade-up",
"padding_y": "lg",
"animation_stagger": 90
},
"children": []
},
{
"type": "stats",
"variant": "cards",
"slot": "default",
"is_enabled": true,
"data": {
"title": {
"ar": "بالأرقام",
"en": "By the numbers"
},
"animate_count": true,
"items": [
{
"icon": "users",
"value": "0",
"suffix": "+",
"label": {
"ar": "لاعب",
"en": "Players"
},
"source": "participants"
},
{
"icon": "building-office",
"value": "0",
"label": {
"ar": "فرع",
"en": "Branches"
},
"source": "branches"
},
{
"icon": "academic-cap",
"value": "0",
"label": {
"ar": "برنامج",
"en": "Programs"
},
"source": "programs"
}
]
},
"style": {
"animation": "fade-up",
"padding_y": "lg",
"animation_stagger": 90
},
"children": []
}
]
},
{
"slug": "activities",
"title": "الأنشطة",
"title_en": "Activities",
"layout": "default",
"is_homepage": false,
"is_published": true,
"sort_order": 2,
"blocks": [
{
"type": "data_activities",
"variant": "image_overlay",
"slot": "default",
"is_enabled": true,
"data": {
"title": {
"ar": "الأنشطة",
"en": "Activities"
},
"columns": "3",
"show_description": true,
"show_image": true
},
"style": {
"animation": "fade-up",
"padding_y": "lg",
"animation_stagger": 90
},
"children": []
}
]
},
{
"slug": "app",
"title": "التطبيق",
"title_en": "App",
"layout": "default",
"is_homepage": false,
"is_published": true,
"sort_order": 3,
"blocks": [
{
"type": "app_download",
"variant": "split",
"slot": "default",
"is_enabled": true,
"data": {
"eyebrow": {
"ar": "تطبيقنا",
"en": "Our app"
},
"title": {
"ar": "حمّل التطبيق",
"en": "Download the app"
},
"description": {
"ar": "<p>تابع كل شيء من هاتفك.</p>",
"en": "<p>Follow everything from your phone.</p>"
},
"screenshot": "",
"ios_url": "",
"android_url": "",
"features": [
{
"icon": "calendar",
"title": {
"ar": "المواعيد",
"en": "Schedule"
},
"body": {
"ar": "—",
"en": "—"
}
},
{
"icon": "chart-bar",
"title": {
"ar": "المتابعة",
"en": "Progress"
},
"body": {
"ar": "—",
"en": "—"
}
}
]
},
"style": {
"animation": "fade-up",
"padding_y": "lg"
},
"children": []
}
]
},
{
"slug": "contact-us",
"title": "تواصل معنا",
"title_en": "Contact Us",
"layout": "default",
"is_homepage": false,
"is_published": true,
"sort_order": 4,
"blocks": [
{
"type": "contact_form",
"variant": "split_info",
"slot": "default",
"is_enabled": true,
"data": {
"title": {
"ar": "تواصل معنا",
"en": "Contact us"
},
"description": {
"ar": "نسعد بتواصلك.",
"en": "We would love to hear from you."
},
"show_subject": true,
"show_phone": true,
"submit_label": {
"ar": "إرسال",
"en": "Send"
},
"success_message": {
"ar": "تم استلام رسالتك.",
"en": "Your message was received."
}
},
"style": {
"padding_y": "lg",
"animation": "fade-up"
},
"children": [
{
"type": "info_cards",
"variant": "icon_circle",
"slot": "default",
"is_enabled": true,
"data": {
"items": [
{
"icon": "phone",
"label": {
"ar": "اتصل بنا",
"en": "Call us"
},
"value": {
"ar": "—",
"en": "—"
},
"action": "tel",
"action_value": ""
},
{
"icon": "envelope",
"label": {
"ar": "راسلنا",
"en": "Email us"
},
"value": {
"ar": "—",
"en": "—"
},
"action": "mailto",
"action_value": ""
},
{
"icon": "map-pin",
"label": {
"ar": "زُرنا",
"en": "Visit us"
},
"value": {
"ar": "—",
"en": "—"
},
"action": "maps",
"action_value": ""
}
]
},
"style": {},
"children": []
}
]
},
{
"type": "map",
"variant": "full_width",
"slot": "default",
"is_enabled": true,
"data": {
"use_branch_data": true,
"height": "md",
"zoom": 12
},
"style": {
"padding_y": "none"
},
"children": []
}
]
},
{
"slug": "terms-and-conditions",
"title": "الشروط والأحكام",
"title_en": "Terms",
"layout": "narrow",
"is_homepage": false,
"is_published": true,
"sort_order": 5,
"blocks": [
{
"type": "rich_text",
"variant": "with_toc",
"slot": "default",
"is_enabled": true,
"data": {
"title": {
"ar": "الشروط والأحكام",
"en": "Terms and conditions"
},
"last_updated": {
"ar": "—",
"en": "—"
},
"sections": [
{
"heading": {
"ar": "القسم الأول",
"en": "Section one"
},
"body": {
"ar": "<p>—</p>",
"en": "<p>—</p>"
},
"anchor": "sec-1"
}
]
},
"style": {
"padding_y": "lg"
},
"children": []
}
]
},
{
"slug": "refund-policy",
"title": "سياسة الاسترجاع",
"title_en": "Refund Policy",
"layout": "narrow",
"is_homepage": false,
"is_published": true,
"sort_order": 6,
"blocks": [
{
"type": "rich_text",
"variant": "prose_narrow",
"slot": "default",
"is_enabled": true,
"data": {
"title": {
"ar": "سياسة الاسترجاع",
"en": "Refund policy"
},
"sections": [
{
"heading": {
"ar": "الاسترجاع",
"en": "Refunds"
},
"body": {
"ar": "<p>—</p>",
"en": "<p>—</p>"
},
"anchor": "refund"
}
]
},
"style": {
"padding_y": "lg"
},
"children": []
}
]
}
]
}
\ No newline at end of file
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Website Builder v3 — the page + block tree.
*
* Additive only. `website_sections` is left untouched so the legacy renderer
* keeps working for every already-deployed tenant; the entrypoint runs
* `migrate --force` on every container start, so nothing here may be destructive.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('website_pages')) {
Schema::create('website_pages', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained('academies');
$table->uuid('uuid')->unique();
$table->string('slug', 160);
$table->string('title')->nullable(); // Arabic (default locale)
$table->string('title_en')->nullable();
// SEO
$table->string('meta_title', 180)->nullable();
$table->string('meta_title_en', 180)->nullable();
$table->string('meta_description', 320)->nullable();
$table->string('meta_description_en', 320)->nullable();
$table->string('og_image_path', 500)->nullable();
$table->boolean('noindex')->default(false);
$table->string('layout', 30)->default('default');
$table->boolean('is_homepage')->default(false);
$table->boolean('is_published')->default(false);
$table->timestamp('published_at')->nullable();
$table->unsignedSmallInteger('sort_order')->default(0);
$table->jsonb('settings')->default('{}');
$table->foreignId('created_by')->nullable()->constrained('users');
$table->timestamps();
$table->softDeletes();
$table->unique(['academy_id', 'slug']);
$table->index(['academy_id', 'is_published']);
$table->index(['academy_id', 'sort_order']);
});
DB::statement("ALTER TABLE website_pages ADD CONSTRAINT website_pages_layout_check
CHECK (layout IN ('default', 'full_width', 'narrow', 'blank', 'landing'))");
// Exactly one homepage per academy — but unlimited non-homepages.
DB::statement('CREATE UNIQUE INDEX website_pages_single_homepage
ON website_pages (academy_id) WHERE is_homepage AND deleted_at IS NULL');
}
if (! Schema::hasTable('website_blocks')) {
Schema::create('website_blocks', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained('academies');
$table->uuid('uuid')->unique();
// Null page_id = a reusable/global block (navbar, footer, popup).
$table->foreignId('website_page_id')->nullable()
->constrained('website_pages')->cascadeOnDelete();
$table->foreignId('parent_id')->nullable()
->constrained('website_blocks')->cascadeOnDelete();
$table->string('slot', 40)->default('default');
$table->string('type', 50);
$table->string('variant', 50)->default('default');
$table->unsignedSmallInteger('sort_order')->default(0);
$table->boolean('is_enabled')->default(true);
// Content fields. Bilingual values live inline as {"ar": "...", "en": "..."}.
$table->jsonb('data')->default('{}');
// Presentation: background, padding, animation, visibility, custom classes.
$table->jsonb('style')->default('{}');
$table->timestamps();
$table->index(['academy_id', 'website_page_id', 'sort_order']);
$table->index(['academy_id', 'parent_id', 'sort_order']);
$table->index(['academy_id', 'type']);
});
// A block belongs to a page OR to a parent — never floating with neither.
DB::statement('ALTER TABLE website_blocks ADD CONSTRAINT website_blocks_parent_or_page_check
CHECK (website_page_id IS NOT NULL OR parent_id IS NOT NULL)');
}
}
public function down(): void
{
Schema::dropIfExists('website_blocks');
Schema::dropIfExists('website_pages');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasColumn('contact_submissions', 'subject')) {
Schema::table('contact_submissions', function (Blueprint $table) {
$table->string('subject', 200)->nullable()->after('email');
});
}
}
public function down(): void
{
if (Schema::hasColumn('contact_submissions', 'subject')) {
Schema::table('contact_submissions', function (Blueprint $table) {
$table->dropColumn('subject');
});
}
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Authored navigation. Previously the navbar was derived from enabled sections,
* so a client could not add a dropdown, an external link, or reorder items.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('website_menus')) {
Schema::create('website_menus', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained('academies');
$table->uuid('uuid')->unique();
$table->string('key', 40);
$table->string('name', 120)->nullable();
$table->timestamps();
$table->unique(['academy_id', 'key']);
});
DB::statement("ALTER TABLE website_menus ADD CONSTRAINT website_menus_key_check
CHECK (key IN ('primary', 'footer', 'footer_secondary', 'mobile', 'utility'))");
}
if (! Schema::hasTable('website_menu_items')) {
Schema::create('website_menu_items', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained('academies');
$table->foreignId('website_menu_id')->constrained('website_menus')->cascadeOnDelete();
$table->foreignId('parent_id')->nullable()
->constrained('website_menu_items')->cascadeOnDelete();
$table->string('label')->nullable();
$table->string('label_en')->nullable();
$table->string('link_type', 20)->default('page');
$table->foreignId('website_page_id')->nullable()
->constrained('website_pages')->nullOnDelete();
$table->string('url', 500)->nullable();
$table->string('anchor', 120)->nullable();
$table->string('route_name', 120)->nullable();
$table->string('icon', 60)->nullable();
$table->boolean('open_in_new_tab')->default(false);
$table->boolean('is_visible')->default(true);
$table->boolean('highlight')->default(false);
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
$table->index(['academy_id', 'website_menu_id', 'sort_order']);
$table->index(['academy_id', 'parent_id', 'sort_order']);
});
DB::statement("ALTER TABLE website_menu_items ADD CONSTRAINT website_menu_items_link_type_check
CHECK (link_type IN ('page', 'url', 'anchor', 'route', 'none'))");
}
}
public function down(): void
{
Schema::dropIfExists('website_menu_items');
Schema::dropIfExists('website_menus');
}
};
...@@ -658,3 +658,127 @@ ...@@ -658,3 +658,127 @@
transform: none !important; transform: none !important;
} }
} }
/* ============================================================
Website Builder v3 — extended motion library
Adds effects, per-block delay, child stagger and parallax on
top of the existing [data-animation] observer.
============================================================ */
@layer components {
/* --- additional entrance effects --- */
[data-animation="slide-up"] { transform: translateY(60px); }
[data-animation="slide-down"] { transform: translateY(-60px); }
[data-animation="slide-start"] { transform: translateX(60px); }
[data-animation="slide-end"] { transform: translateX(-60px); }
[data-animation="scale-in"] { transform: scale(0.85); }
[data-animation="scale-out"] { transform: scale(1.15); }
[data-animation="rotate-in"] { transform: rotate(-6deg) scale(0.95); }
[data-animation="blur-in"] { filter: blur(12px); }
[data-animation="reveal-up"] { clip-path: inset(100% 0 0 0); }
[data-animation="flip-y"] { transform: perspective(800px) rotateY(20deg); }
[data-animation="blur-in"].animated { filter: blur(0); }
[data-animation="reveal-up"].animated { clip-path: inset(0 0 0 0); }
/* --- per-block delay --- */
[data-animation][data-animation-delay="100"] { transition-delay: 0.1s; }
[data-animation][data-animation-delay="200"] { transition-delay: 0.2s; }
[data-animation][data-animation-delay="300"] { transition-delay: 0.3s; }
[data-animation][data-animation-delay="400"] { transition-delay: 0.4s; }
[data-animation][data-animation-delay="600"] { transition-delay: 0.6s; }
[data-animation][data-animation-delay="800"] { transition-delay: 0.8s; }
/* --- staggered children: set by the observer as --stagger-index --- */
[data-animation-stagger] .ec-stagger-item {
opacity: 0;
transform: translateY(24px);
transition:
opacity var(--anim-duration, 0.7s) ease-out,
transform var(--anim-duration, 0.7s) ease-out;
transition-delay: calc(var(--stagger-index, 0) * var(--stagger-step, 90ms));
}
[data-animation-stagger].animated .ec-stagger-item {
opacity: 1;
transform: none;
}
/* --- continuous/ambient effects (opt-in per element) --- */
.ec-float { animation: ec-float 6s ease-in-out infinite; }
.ec-glow { animation: ec-glow 3s ease-in-out infinite; }
.ec-pulse-soft { animation: ec-pulse-soft 2.5s ease-in-out infinite; }
.ec-bounce-subtle { animation: ec-bounce-subtle 3s ease-in-out infinite; }
.ec-shimmer {
position: relative;
overflow: hidden;
}
.ec-shimmer::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
110deg,
transparent 30%,
rgb(255 255 255 / 0.35) 50%,
transparent 70%
);
transform: translateX(-100%);
animation: ec-shimmer 2.8s ease-in-out infinite;
}
@keyframes ec-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-14px); }
}
@keyframes ec-glow {
0%, 100% { filter: drop-shadow(0 0 6px rgb(255 255 255 / 0.25)); }
50% { filter: drop-shadow(0 0 22px rgb(255 255 255 / 0.6)); }
}
@keyframes ec-pulse-soft {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.85; transform: scale(1.03); }
}
@keyframes ec-bounce-subtle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes ec-shimmer {
0% { transform: translateX(-100%); }
60%, 100% { transform: translateX(100%); }
}
/* --- parallax backgrounds --- */
[data-parallax="true"] {
will-change: transform;
transform: translate3d(0, var(--parallax-offset, 0), 0) scale(1.15);
}
/* Motion is a preference, not a decoration: honour the OS setting and the
academy-level animations toggle for everything added above too. */
[data-animations="false"] .ec-float,
[data-animations="false"] .ec-glow,
[data-animations="false"] .ec-pulse-soft,
[data-animations="false"] .ec-bounce-subtle,
[data-animations="false"] .ec-shimmer::after,
[data-animations="false"] [data-parallax] {
animation: none !important;
transform: none !important;
}
[data-animations="false"] [data-animation-stagger] .ec-stagger-item {
opacity: 1;
transform: none;
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.ec-float, .ec-glow, .ec-pulse-soft, .ec-bounce-subtle, .ec-shimmer::after {
animation: none !important;
}
[data-parallax] { transform: none !important; }
[data-animation-stagger] .ec-stagger-item {
opacity: 1;
transform: none;
transition: none;
}
}
}
...@@ -198,3 +198,79 @@ function initFAQAccordion() { ...@@ -198,3 +198,79 @@ function initFAQAccordion() {
}); });
}); });
} }
/* ============================================================
Website Builder v3 — stagger + parallax
Complements the existing [data-animation] observer.
============================================================ */
(function () {
const motionAllowed = () =>
document.body?.dataset.animations !== 'false' &&
!window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/**
* Tags the direct children of a staggered block with an index so CSS can
* cascade their transition-delay without hard-coding a child count.
*/
function initStagger() {
document.querySelectorAll('[data-animation-stagger]').forEach((block) => {
const step = parseInt(block.dataset.animationStagger || '0', 10);
if (!step) return;
block.style.setProperty('--stagger-step', `${step}ms`);
const targets = block.querySelectorAll('.ec-stagger-item');
targets.forEach((el, i) => el.style.setProperty('--stagger-index', i));
});
}
/**
* Background parallax. Uses rAF-throttled scroll rather than a scroll
* handler doing layout work on every event.
*/
function initParallax() {
const layers = Array.from(document.querySelectorAll('[data-parallax="true"]'));
if (!layers.length || !motionAllowed()) return;
let ticking = false;
const update = () => {
const viewportH = window.innerHeight;
layers.forEach((layer) => {
const host = layer.parentElement;
if (!host) return;
const rect = host.getBoundingClientRect();
if (rect.bottom < 0 || rect.top > viewportH) return;
// -1..1 across the viewport, scaled to a gentle offset.
const progress = (rect.top + rect.height / 2 - viewportH / 2) / viewportH;
layer.style.setProperty('--parallax-offset', `${(progress * -40).toFixed(2)}px`);
});
ticking = false;
};
const onScroll = () => {
if (ticking) return;
ticking = true;
window.requestAnimationFrame(update);
};
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll, { passive: true });
update();
}
function init() {
initStagger();
initParallax();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
...@@ -91,6 +91,8 @@ ...@@ -91,6 +91,8 @@
]], ]],
['section' => 'الموقع الإلكتروني', 'items' => [ ['section' => 'الموقع الإلكتروني', 'items' => [
['label' => 'صفحات الموقع', 'route' => 'website.manage.pages', 'icon' => 'document-text', 'permission' => 'settings.manage'],
['label' => 'قوائم التنقل', 'route' => 'website.manage.menus', 'icon' => 'squares-2x2', 'permission' => 'settings.manage'],
['label' => 'أقسام الموقع', 'route' => 'website.manage.sections', 'icon' => 'grid', 'permission' => 'settings.manage'], ['label' => 'أقسام الموقع', 'route' => 'website.manage.sections', 'icon' => 'grid', 'permission' => 'settings.manage'],
['label' => 'المظهر والألوان', 'route' => 'website.manage.theme', 'icon' => 'swatch', 'permission' => 'settings.manage'], ['label' => 'المظهر والألوان', 'route' => 'website.manage.theme', 'icon' => 'swatch', 'permission' => 'settings.manage'],
['label' => 'معرض الصور', 'route' => 'website.manage.gallery', 'icon' => 'photo', 'permission' => 'settings.manage'], ['label' => 'معرض الصور', 'route' => 'website.manage.gallery', 'icon' => 'photo', 'permission' => 'settings.manage'],
......
{{--
Icon resolver for the public website.
Order of resolution:
1. brand/social glyph shipped in resources/views/website/icons
2. supplementary outline icons defined below
3. the app-wide <x-ui.icon> set
4. a visible neutral placeholder
Step 4 matters: <x-ui.icon> renders nothing for an unknown name, which in a
builder context looks like a broken layout with no explanation. A visible
placeholder tells the editor the icon name is wrong.
--}}
@props(['name' => null, 'class' => 'w-5 h-5'])
@php
$key = \Illuminate\Support\Str::of((string) $name)->lower()->trim()->replace([' ', '_'], '-')->value();
$aliases = [
'x' => 'twitter', 'x-twitter' => 'twitter', 'fb' => 'facebook',
'ig' => 'instagram', 'yt' => 'youtube', 'location' => 'map-pin',
'mail' => 'envelope', 'email' => 'envelope', 'tel' => 'phone',
'mobile' => 'device-phone-mobile', 'close' => 'x-mark', 'tick' => 'check',
];
$key = $aliases[$key] ?? $key;
$socialView = 'website.icons.' . $key;
// Outline paths on a 24x24 grid, stroked with currentColor.
$supplementary = [
'map-pin' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 21s7-5.686 7-11a7 7 0 10-14 0c0 5.314 7 11 7 11z"/><circle cx="12" cy="10" r="2.5" stroke-width="2"/>',
'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"/>',
'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"/>',
'device-phone-mobile' => '<rect x="7" y="3" width="10" height="18" rx="2" stroke-width="2"/><path stroke-linecap="round" stroke-width="2" d="M11 18.5h2"/>',
'arrow-down-tray' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v12m0 0l-4-4m4 4l4-4M4 19h16"/>',
'link' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 13a4 4 0 006 .5l2.5-2.5a4 4 0 00-5.66-5.66L11.5 6.7M14 11a4 4 0 00-6-.5L5.5 13a4 4 0 005.66 5.66l1.3-1.3"/>',
'share' => '<circle cx="18" cy="6" r="2.5" stroke-width="2"/><circle cx="6" cy="12" r="2.5" stroke-width="2"/><circle cx="18" cy="18" r="2.5" stroke-width="2"/><path stroke-width="2" d="M8.3 10.8l7.4-3.6M8.3 13.2l7.4 3.6"/>',
'whatsapp' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.5 20.5l1.3-4a8 8 0 113.2 3.1z"/><path stroke-linecap="round" stroke-width="2" d="M9 9.5c0 3 2.5 5.5 5.5 5.5"/>',
'squares-2x2' => '<rect x="3" y="3" width="7" height="7" rx="1.5" stroke-width="2"/><rect x="14" y="3" width="7" height="7" rx="1.5" stroke-width="2"/><rect x="3" y="14" width="7" height="7" rx="1.5" stroke-width="2"/><rect x="14" y="14" width="7" height="7" rx="1.5" stroke-width="2"/>',
'view-columns' => '<rect x="3" y="4" width="18" height="16" rx="2" stroke-width="2"/><path stroke-width="2" d="M9 4v16M15 4v16"/>',
'rectangle-group' => '<rect x="3" y="4" width="8" height="7" rx="1.5" stroke-width="2"/><rect x="13" y="4" width="8" height="12" rx="1.5" stroke-width="2"/><rect x="3" y="13" width="8" height="7" rx="1.5" stroke-width="2"/>',
'identification' => '<rect x="3" y="5" width="18" height="14" rx="2" stroke-width="2"/><circle cx="9" cy="11" r="2" stroke-width="2"/><path stroke-linecap="round" stroke-width="2" d="M6 16c.8-1.3 1.8-2 3-2s2.2.7 3 2M14.5 10H18M14.5 13.5H18"/>',
'arrows-up-down' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 4v16m0-16L5 7m3-3l3 3M16 20V4m0 16l-3-3m3 3l3-3"/>',
];
@endphp
@if (view()->exists($socialView))
@include($socialView, ['class' => $class])
@elseif (isset($supplementary[$key]))
<svg {{ $attributes->merge(['class' => $class]) }} fill="none" stroke="currentColor"
viewBox="0 0 24 24" aria-hidden="true" focusable="false">{!! $supplementary[$key] !!}</svg>
@elseif ($key !== '')
@php
// Probe the shared set; it renders empty output for unknown names.
$shared = trim(\Illuminate\Support\Facades\Blade::render(
'<x-ui.icon :name="$n" :class="$c" />',
['n' => $key, 'c' => $class],
));
@endphp
@if ($shared !== '')
{!! $shared !!}
@else
<svg {{ $attributes->merge(['class' => $class]) }} fill="none" stroke="currentColor"
viewBox="0 0 24 24" aria-hidden="true" focusable="false"
data-unknown-icon="{{ $key }}">
<rect x="3.5" y="3.5" width="17" height="17" rx="3" stroke-width="1.5" stroke-dasharray="3 3"/>
</svg>
@endif
@endif
{{--
Image / gallery input for the builder.
Accepts a direct URL or an upload; uploads are routed through the builder's
updatedUpload() so nested repeater paths resolve correctly.
--}}
@props(['path', 'id' => null, 'multiple' => false])
@php
$inputId = $id ?: 'm-' . \Illuminate\Support\Str::slug(str_replace('.', '-', $path));
$key = \Illuminate\Support\Str::after($path, 'form.');
@endphp
<div class="flex flex-col gap-2" wire:key="media-{{ $inputId }}">
@php $current = data_get($this->form, $key); @endphp
@if ($multiple)
@if (is_array($current) && $current)
<div class="grid grid-cols-4 gap-2">
@foreach ($current as $i => $img)
<div class="relative group" wire:key="{{ $inputId }}-img-{{ $i }}">
<img src="{{ is_array($img) ? data_get($img, 'url') : $img }}" alt=""
class="w-full aspect-square object-cover rounded-lg border">
<button type="button" wire:click="removeMedia('{{ $path }}', {{ $i }})"
class="absolute top-1 end-1 rounded-full bg-red-600 text-white w-6 h-6 grid place-items-center opacity-0 group-hover:opacity-100 focus:opacity-100"
aria-label="{{ __('حذف الصورة') }}">
<x-ui.icon name="x-mark" class="w-3.5 h-3.5" />
</button>
</div>
@endforeach
</div>
@endif
@elseif ($current)
<div class="relative inline-block">
<img src="{{ $current }}" alt="" class="h-28 w-auto rounded-lg border object-cover">
<button type="button" wire:click="removeMedia('{{ $path }}')"
class="absolute top-1 end-1 rounded-full bg-red-600 text-white w-6 h-6 grid place-items-center"
aria-label="{{ __('حذف الصورة') }}">
<x-ui.icon name="x-mark" class="w-3.5 h-3.5" />
</button>
</div>
@endif
<div class="flex items-center gap-2">
<input id="{{ $inputId }}" type="text" wire:model="{{ $path }}" dir="ltr"
@if ($multiple) disabled placeholder="{{ __('ارفع صورًا بالأسفل') }}"
@else placeholder="{{ __('رابط الصورة أو ارفع ملفًا') }}" @endif
class="flex-1 rounded-lg border px-3 py-2 text-sm disabled:bg-gray-50">
<label class="shrink-0 cursor-pointer rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50">
<span wire:loading.remove wire:target="upload">{{ __('رفع') }}</span>
<span wire:loading wire:target="upload">{{ __('جارٍ...') }}</span>
<input type="file" accept="image/*" class="sr-only"
x-on:click="$wire.setUploadTarget('{{ $path }}')"
wire:model="upload">
</label>
</div>
@error('upload') <p class="text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div>
@if ($sent)
<div class="ec-surface rounded-2xl p-8 text-center flex flex-col gap-3" role="status" aria-live="polite">
<span class="ec-accent mx-auto inline-flex items-center justify-center w-14 h-14 rounded-full border">
<x-website.icon name="check" class="w-7 h-7" />
</span>
<p class="font-semibold text-lg">{{ $successMessage ?: __('تم استلام رسالتك بنجاح') }}</p>
<button type="button" wire:click="$set('sent', false)" class="ec-accent text-sm underline mx-auto">
{{ __('إرسال رسالة أخرى') }}
</button>
</div>
@else
<form wire:submit="submit" class="flex flex-col gap-4">
{{-- Honeypot: visually hidden, never announced to assistive tech. --}}
<div class="hidden" aria-hidden="true">
<label>{{ __('اترك هذا الحقل فارغًا') }}
<input type="text" wire:model="website" tabindex="-1" autocomplete="off">
</label>
</div>
<div class="grid sm:grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label for="cf-name-{{ $blockId }}" class="text-sm font-medium">{{ __('الاسم') }} <span aria-hidden="true">*</span></label>
<input id="cf-name-{{ $blockId }}" type="text" wire:model="name" required
class="rounded-xl border px-4 py-3 focus:outline-none focus:ring-2"
@error('name') aria-invalid="true" aria-describedby="cf-name-err-{{ $blockId }}" @enderror>
@error('name') <p id="cf-name-err-{{ $blockId }}" class="text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col gap-1.5">
<label for="cf-email-{{ $blockId }}" class="text-sm font-medium">{{ __('البريد الإلكتروني') }} <span aria-hidden="true">*</span></label>
<input id="cf-email-{{ $blockId }}" type="email" wire:model="email" required dir="ltr"
class="rounded-xl border px-4 py-3 focus:outline-none focus:ring-2"
@error('email') aria-invalid="true" @enderror>
@error('email') <p class="text-sm text-red-600">{{ $message }}</p> @enderror
</div>
</div>
@if ($showPhone)
<div class="flex flex-col gap-1.5">
<label for="cf-phone-{{ $blockId }}" class="text-sm font-medium">
{{ __('رقم الهاتف') }} @if ($requirePhone) <span aria-hidden="true">*</span> @endif
</label>
<input id="cf-phone-{{ $blockId }}" type="tel" wire:model="phone" dir="ltr"
@if ($requirePhone) required @endif
class="rounded-xl border px-4 py-3 focus:outline-none focus:ring-2">
@error('phone') <p class="text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@endif
@if ($showSubject)
<div class="flex flex-col gap-1.5">
<label for="cf-subject-{{ $blockId }}" class="text-sm font-medium">{{ __('الموضوع') }}</label>
<input id="cf-subject-{{ $blockId }}" type="text" wire:model="subject"
class="rounded-xl border px-4 py-3 focus:outline-none focus:ring-2">
@error('subject') <p class="text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@endif
<div class="flex flex-col gap-1.5">
<label for="cf-message-{{ $blockId }}" class="text-sm font-medium">{{ __('رسالتك') }} <span aria-hidden="true">*</span></label>
<textarea id="cf-message-{{ $blockId }}" wire:model="message" rows="5" required
class="rounded-xl border px-4 py-3 focus:outline-none focus:ring-2"></textarea>
@error('message') <p class="text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<button type="submit" wire:loading.attr="disabled" wire:target="submit"
class="ec-btn ec-btn--primary rounded-full px-8 py-3.5 font-semibold transition self-start">
<span wire:loading.remove wire:target="submit">{{ $submitLabel ?: __('إرسال') }}</span>
<span wire:loading wire:target="submit">{{ __('جارٍ الإرسال...') }}</span>
</button>
</form>
@endif
</div>
<div class="p-6 space-y-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold">{{ __('قوائم التنقل') }}</h1>
<p class="text-sm text-gray-500 mt-1">{{ __('تحكّم كامل في روابط الشريط العلوي والتذييل، بما في ذلك القوائم المنسدلة') }}</p>
</div>
@can('settings.manage')
<button type="button" wire:click="addItem"
class="inline-flex items-center gap-2 rounded-lg bg-primary-600 px-4 py-2.5 text-white font-medium">
<x-ui.icon name="plus" class="w-5 h-5" /> {{ __('عنصر جديد') }}
</button>
@endcan
</div>
@if (session('success'))
<div class="rounded-lg bg-green-50 border border-green-200 text-green-800 px-4 py-3">{{ session('success') }}</div>
@endif
<div class="flex flex-wrap gap-2" role="tablist">
@foreach ($menuKeys as $key)
<button type="button" wire:click="$set('menuKey', '{{ $key }}')" role="tab"
aria-selected="{{ $menuKey === $key ? 'true' : 'false' }}"
class="rounded-lg px-4 py-2 text-sm font-medium {{ $menuKey === $key ? 'bg-primary-600 text-white' : 'border hover:bg-gray-50' }}">
{{ \App\Domain\Website\Models\WebsiteMenu::labelFor($key) }}
</button>
@endforeach
</div>
@if ($showForm)
<div class="rounded-xl border bg-white p-6 space-y-4">
<h2 class="font-semibold">{{ $editingId ? __('تعديل العنصر') : __('عنصر جديد') }}</h2>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label for="mi-label" class="block text-sm font-medium mb-1.5">{{ __('النص (عربي)') }} *</label>
<input id="mi-label" type="text" wire:model="label" class="w-full rounded-lg border px-3 py-2">
@error('label') <p class="text-sm text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="mi-label-en" class="block text-sm font-medium mb-1.5">{{ __('النص (إنجليزي)') }}</label>
<input id="mi-label-en" type="text" wire:model="label_en" dir="ltr" class="w-full rounded-lg border px-3 py-2">
</div>
<div>
<label for="mi-type" class="block text-sm font-medium mb-1.5">{{ __('نوع الرابط') }}</label>
<select id="mi-type" wire:model.live="link_type" class="w-full rounded-lg border px-3 py-2">
<option value="page">{{ __('صفحة من الموقع') }}</option>
<option value="url">{{ __('رابط خارجي') }}</option>
<option value="anchor">{{ __('قسم داخل الصفحة') }}</option>
<option value="none">{{ __('قائمة منسدلة (بدون رابط)') }}</option>
</select>
@error('link_type') <p class="text-sm text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
@if ($link_type === 'page')
<div>
<label for="mi-page" class="block text-sm font-medium mb-1.5">{{ __('الصفحة') }}</label>
<select id="mi-page" wire:model="website_page_id" class="w-full rounded-lg border px-3 py-2">
<option value="">{{ __('— اختر —') }}</option>
@foreach ($pages as $p)
<option value="{{ $p->id }}">{{ $p->title ?: $p->slug }}</option>
@endforeach
</select>
@error('website_page_id') <p class="text-sm text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
@elseif ($link_type === 'url')
<div>
<label for="mi-url" class="block text-sm font-medium mb-1.5">{{ __('الرابط') }}</label>
<input id="mi-url" type="url" wire:model="url" dir="ltr" placeholder="https://..." class="w-full rounded-lg border px-3 py-2">
@error('url') <p class="text-sm text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
@elseif ($link_type === 'anchor')
<div>
<label for="mi-anchor" class="block text-sm font-medium mb-1.5">{{ __('معرّف القسم') }}</label>
<input id="mi-anchor" type="text" wire:model="anchor" dir="ltr" placeholder="about" class="w-full rounded-lg border px-3 py-2 font-mono text-sm">
</div>
@endif
<div>
<label for="mi-icon" class="block text-sm font-medium mb-1.5">{{ __('أيقونة') }}</label>
<input id="mi-icon" type="text" wire:model="icon" dir="ltr" list="ec-icon-names" class="w-full rounded-lg border px-3 py-2 font-mono text-sm">
</div>
</div>
<div class="flex flex-wrap gap-6">
<label class="inline-flex items-center gap-2"><input type="checkbox" wire:model="is_visible" class="rounded"> <span>{{ __('ظاهر') }}</span></label>
<label class="inline-flex items-center gap-2"><input type="checkbox" wire:model="highlight" class="rounded"> <span>{{ __('إبراز كزر') }}</span></label>
<label class="inline-flex items-center gap-2"><input type="checkbox" wire:model="open_in_new_tab" class="rounded"> <span>{{ __('فتح في تبويب جديد') }}</span></label>
</div>
<div class="flex gap-3">
<button type="button" wire:click="save" wire:loading.attr="disabled" wire:target="save" class="rounded-lg bg-primary-600 px-5 py-2.5 text-white font-medium">
<span wire:loading.remove wire:target="save">{{ __('حفظ') }}</span>
<span wire:loading wire:target="save">{{ __('جارٍ الحفظ...') }}</span>
</button>
<button type="button" wire:click="$set('showForm', false)" class="rounded-lg border px-5 py-2.5">{{ __('إلغاء') }}</button>
</div>
</div>
@endif
<div class="rounded-xl border bg-white p-4" wire:loading.class="opacity-50">
@if ($items->isEmpty())
<p class="text-center text-gray-500 py-10">{{ __('لا توجد عناصر في هذه القائمة بعد') }}</p>
@else
<ul class="space-y-1">
@foreach ($items as $item)
@include('website.builder.menu-row', ['item' => $item, 'depth' => 0])
@endforeach
</ul>
@endif
</div>
<datalist id="ec-icon-names">
@foreach (['home','users','calendar','trophy','phone','envelope','map-pin','academic-cap','building-office','star'] as $n)
<option value="{{ $n }}"></option>
@endforeach
</datalist>
</div>
<div class="flex flex-col h-[calc(100vh-4rem)]" x-data="{ }">
{{-- Toolbar --}}
<div class="flex flex-wrap items-center justify-between gap-3 border-b bg-white px-5 py-3">
<div class="flex items-center gap-3 min-w-0">
<a href="{{ route('website.manage.pages') }}" wire:navigate class="text-gray-500 hover:text-gray-800" aria-label="{{ __('رجوع') }}">
<x-ui.icon name="chevron-right" class="w-5 h-5" />
</a>
<div class="min-w-0">
<h1 class="font-bold truncate">{{ $page->title ?: $page->title_en ?: $page->slug }}</h1>
<p class="text-xs text-gray-500 font-mono" dir="ltr">/{{ $page->slug }}</p>
</div>
<span class="text-xs px-2 py-1 rounded-full {{ $page->is_published ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600' }}">
{{ $page->is_published ? __('منشورة') : __('مسودة') }}
</span>
</div>
<div class="flex items-center gap-2">
<div class="hidden md:flex items-center rounded-lg border overflow-hidden" role="group" aria-label="{{ __('عرض المعاينة') }}">
@foreach (['desktop' => 'شاشة', 'tablet' => 'لوحي', 'mobile' => 'جوال'] as $vp => $vpLabel)
<button type="button" wire:click="$set('previewport', '{{ $vp }}')"
class="px-3 py-1.5 text-sm {{ $previewport === $vp ? 'bg-primary-600 text-white' : 'hover:bg-gray-50' }}">
{{ __($vpLabel) }}
</button>
@endforeach
</div>
<a href="{{ route('website.manage.pages.preview', $page->id) }}" target="_blank" rel="noopener"
class="rounded-lg border px-4 py-2 text-sm font-medium">{{ __('معاينة') }}</a>
</div>
</div>
@if (session('success'))
<div class="bg-green-50 border-b border-green-200 text-green-800 px-5 py-2 text-sm">{{ session('success') }}</div>
@endif
@if (session('error'))
<div class="bg-red-50 border-b border-red-200 text-red-800 px-5 py-2 text-sm">{{ session('error') }}</div>
@endif
<div class="flex flex-1 min-h-0">
{{-- Block tree --}}
<aside class="w-72 shrink-0 border-e bg-white overflow-y-auto">
<div class="p-3 border-b">
<button type="button" wire:click="openPicker"
class="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-primary-600 px-4 py-2.5 text-white text-sm font-medium hover:bg-primary-700">
<x-ui.icon name="plus" class="w-4 h-4" /> {{ __('إضافة عنصر') }}
</button>
</div>
@if ($tree->isEmpty())
<p class="p-6 text-center text-sm text-gray-500">{{ __('الصفحة فارغة — أضف أول عنصر') }}</p>
@else
<ul class="p-2 space-y-1">
@foreach ($tree as $node)
@include('website.builder.tree-node', ['node' => $node, 'depth' => 0])
@endforeach
</ul>
@endif
</aside>
{{-- Inspector --}}
<section class="flex-1 min-w-0 overflow-y-auto bg-gray-50">
@if (! $block)
<div class="h-full grid place-items-center text-center p-10">
<div class="space-y-2 text-gray-500">
<x-ui.icon name="squares-plus" class="w-10 h-10 mx-auto opacity-40" />
<p>{{ __('اختر عنصرًا من القائمة لتعديله') }}</p>
</div>
</div>
@elseif (! $definition)
<div class="p-6">
<div class="rounded-lg bg-amber-50 border border-amber-200 text-amber-900 p-4">
{{ __('نوع هذا العنصر لم يعد متاحًا في النظام. يمكنك حذفه.') }}
</div>
</div>
@else
<div class="p-5 space-y-5 max-w-3xl">
<div class="flex items-center gap-3">
<span class="inline-flex items-center justify-center w-10 h-10 rounded-lg bg-white border">
<x-website.icon :name="$definition->icon()" class="w-5 h-5" />
</span>
<div>
<h2 class="font-bold">{{ $definition->label() }}</h2>
@if ($definition->description())
<p class="text-xs text-gray-500">{{ $definition->description() }}</p>
@endif
</div>
</div>
{{-- Panel tabs --}}
<div class="flex gap-1 rounded-lg bg-white border p-1" role="tablist">
@foreach (['content' => 'المحتوى', 'design' => 'التصميم', 'motion' => 'الحركة'] as $tab => $tabLabel)
<button type="button" role="tab" wire:click="$set('panel', '{{ $tab }}')"
aria-selected="{{ $panel === $tab ? 'true' : 'false' }}"
class="flex-1 rounded-md px-3 py-2 text-sm font-medium {{ $panel === $tab ? 'bg-primary-600 text-white' : 'hover:bg-gray-50' }}">
{{ __($tabLabel) }}
</button>
@endforeach
</div>
<div class="rounded-xl border bg-white p-5 space-y-4">
@if ($panel === 'content')
@if (count($definition->variants()) > 1)
<div class="flex flex-col gap-1.5">
<label for="pb-variant" class="text-sm font-medium">{{ __('نمط العرض') }}</label>
<select id="pb-variant" wire:model="variant" class="w-full rounded-lg border px-3 py-2 text-sm">
@foreach ($definition->variants() as $value => $variantLabel)
<option value="{{ $value }}">{{ $variantLabel }}</option>
@endforeach
</select>
</div>
@endif
@foreach ($definition->fields() as $field)
@include('website.builder.field', ['field' => $field, 'path' => 'form.' . $field->key])
@endforeach
@elseif ($panel === 'design')
@include('website.builder.style-panel')
@else
@include('website.builder.motion-panel')
@endif
</div>
<div class="flex items-center gap-3 sticky bottom-0 bg-gray-50 py-3">
<button type="button" wire:click="saveBlock" wire:loading.attr="disabled" wire:target="saveBlock"
class="rounded-lg bg-primary-600 px-6 py-2.5 text-white font-medium">
<span wire:loading.remove wire:target="saveBlock">{{ __('حفظ العنصر') }}</span>
<span wire:loading wire:target="saveBlock">{{ __('جارٍ الحفظ...') }}</span>
</button>
<button type="button" wire:click="duplicateBlock({{ $block->id }})" class="rounded-lg border px-4 py-2.5 text-sm">{{ __('نسخ') }}</button>
<button type="button" wire:click="deleteBlock({{ $block->id }})"
wire:confirm="{{ __('حذف هذا العنصر؟') }}"
class="rounded-lg border border-red-200 text-red-600 px-4 py-2.5 text-sm">{{ __('حذف') }}</button>
</div>
</div>
@endif
</section>
</div>
{{-- Block picker --}}
@if ($showPicker)
<div class="fixed inset-0 z-50 bg-black/40 flex items-start justify-center p-6 overflow-y-auto"
wire:click.self="$set('showPicker', false)" role="dialog" aria-modal="true" aria-label="{{ __('اختر عنصرًا') }}">
<div class="bg-white rounded-2xl w-full max-w-4xl mt-10 overflow-hidden">
<div class="flex items-center gap-3 border-b p-4">
<input type="search" wire:model.live.debounce.200ms="pickerSearch" autofocus
placeholder="{{ __('ابحث عن عنصر...') }}"
class="flex-1 rounded-lg border px-4 py-2.5" aria-label="{{ __('بحث في العناصر') }}">
<button type="button" wire:click="$set('showPicker', false)" class="p-2 text-gray-400" aria-label="{{ __('إغلاق') }}">
<x-ui.icon name="x-mark" class="w-5 h-5" />
</button>
</div>
<div class="p-5 space-y-6 max-h-[65vh] overflow-y-auto">
@forelse ($groups as $categoryKey => $types)
<div>
<h3 class="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
{{ \App\Domain\Website\Enums\BlockCategory::from($categoryKey)->label() }}
</h3>
<div class="grid sm:grid-cols-3 gap-3">
@foreach ($types as $type)
<button type="button" wire:click="addBlock('{{ $type->key() }}')"
class="flex items-start gap-3 rounded-xl border p-3 text-start hover:border-primary-500 hover:bg-primary-50/40 transition">
<span class="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg bg-gray-50 border">
<x-website.icon :name="$type->icon()" class="w-4 h-4" />
</span>
<span class="min-w-0">
<span class="block font-medium text-sm">{{ $type->label() }}</span>
@if ($type->description())
<span class="block text-xs text-gray-500 line-clamp-2">{{ $type->description() }}</span>
@endif
</span>
</button>
@endforeach
</div>
</div>
@empty
<p class="text-center text-gray-500 py-10">{{ __('لا توجد عناصر مطابقة') }}</p>
@endforelse
</div>
</div>
</div>
@endif
<datalist id="ec-icon-names">
@foreach (['check','x-mark','map-pin','phone','envelope','star','globe','users','calendar','clock','trophy','academic-cap','building-office','share','link','whatsapp','facebook','instagram','youtube','tiktok','twitter','device-phone-mobile','arrow-down-tray'] as $iconName)
<option value="{{ $iconName }}"></option>
@endforeach
</datalist>
</div>
<div class="p-6 space-y-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold">{{ __('صفحات الموقع') }}</h1>
<p class="text-sm text-gray-500 mt-1">{{ __('أنشئ أي عدد من الصفحات وصمّمها بالكامل من المنشئ') }}</p>
</div>
@can('settings.manage')
<button type="button" wire:click="create"
class="inline-flex items-center gap-2 rounded-lg bg-primary-600 px-4 py-2.5 text-white font-medium hover:bg-primary-700">
<x-ui.icon name="plus" class="w-5 h-5" />
{{ __('صفحة جديدة') }}
</button>
@endcan
</div>
@if (session('success'))
<div class="rounded-lg bg-green-50 border border-green-200 text-green-800 px-4 py-3">{{ session('success') }}</div>
@endif
@if (session('error'))
<div class="rounded-lg bg-red-50 border border-red-200 text-red-800 px-4 py-3">{{ session('error') }}</div>
@endif
{{-- Create / edit panel --}}
@if ($showForm)
<div class="rounded-xl border bg-white p-6 space-y-4">
<h2 class="font-semibold text-lg">{{ $editingId ? __('تعديل الصفحة') : __('صفحة جديدة') }}</h2>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label for="pm-title" class="block text-sm font-medium mb-1.5">{{ __('اسم الصفحة (عربي)') }} *</label>
<input id="pm-title" type="text" wire:model="title" class="w-full rounded-lg border px-3 py-2">
@error('title') <p class="text-sm text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="pm-title-en" class="block text-sm font-medium mb-1.5">{{ __('اسم الصفحة (إنجليزي)') }}</label>
<input id="pm-title-en" type="text" wire:model="title_en" dir="ltr" class="w-full rounded-lg border px-3 py-2">
@error('title_en') <p class="text-sm text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="pm-slug" class="block text-sm font-medium mb-1.5">{{ __('الرابط') }}</label>
<input id="pm-slug" type="text" wire:model="slug" dir="ltr" placeholder="about-us"
class="w-full rounded-lg border px-3 py-2 font-mono text-sm">
<p class="text-xs text-gray-500 mt-1">{{ __('اتركه فارغًا ليُنشأ تلقائيًا') }}</p>
@error('slug') <p class="text-sm text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="pm-layout" class="block text-sm font-medium mb-1.5">{{ __('التخطيط') }}</label>
<select id="pm-layout" wire:model="layout" class="w-full rounded-lg border px-3 py-2">
<option value="default">{{ __('افتراضي') }}</option>
<option value="full_width">{{ __('عرض كامل') }}</option>
<option value="narrow">{{ __('ضيق') }}</option>
<option value="landing">{{ __('صفحة هبوط') }}</option>
<option value="blank">{{ __('فارغ (بدون رأس أو تذييل)') }}</option>
</select>
@error('layout') <p class="text-sm text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div class="md:col-span-2">
<label for="pm-meta" class="block text-sm font-medium mb-1.5">{{ __('وصف الصفحة لمحركات البحث') }}</label>
<textarea id="pm-meta" wire:model="meta_description" rows="2" class="w-full rounded-lg border px-3 py-2"></textarea>
@error('meta_description') <p class="text-sm text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="flex flex-wrap gap-6">
<label class="inline-flex items-center gap-2">
<input type="checkbox" wire:model="is_published" class="rounded"> <span>{{ __('منشورة') }}</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" wire:model="is_homepage" class="rounded"> <span>{{ __('الصفحة الرئيسية') }}</span>
</label>
</div>
<div class="flex gap-3 pt-2">
<button type="button" wire:click="save" wire:loading.attr="disabled" wire:target="save"
class="rounded-lg bg-primary-600 px-5 py-2.5 text-white font-medium">
<span wire:loading.remove wire:target="save">{{ __('حفظ') }}</span>
<span wire:loading wire:target="save">{{ __('جارٍ الحفظ...') }}</span>
</button>
<button type="button" wire:click="$set('showForm', false)" class="rounded-lg border px-5 py-2.5">{{ __('إلغاء') }}</button>
</div>
</div>
@endif
{{-- Filters --}}
<div class="flex flex-wrap gap-3">
<input type="search" wire:model.live.debounce.300ms="search" placeholder="{{ __('ابحث عن صفحة') }}"
class="rounded-lg border px-3 py-2 flex-1 min-w-56" aria-label="{{ __('بحث') }}">
<select wire:model.live="status" class="rounded-lg border px-3 py-2" aria-label="{{ __('الحالة') }}">
<option value="">{{ __('كل الحالات') }}</option>
<option value="published">{{ __('منشورة') }}</option>
<option value="draft">{{ __('مسودة') }}</option>
</select>
</div>
{{-- Table --}}
<div class="rounded-xl border bg-white overflow-hidden" wire:loading.class="opacity-50 pointer-events-none">
@if ($pages->isEmpty())
<div class="p-12 text-center text-gray-500 space-y-3">
<x-ui.icon name="document-text" class="w-10 h-10 mx-auto opacity-40" />
<p>{{ __('لا توجد صفحات بعد') }}</p>
@can('settings.manage')
<button type="button" wire:click="create" class="text-primary-600 font-medium">{{ __('أنشئ أول صفحة') }}</button>
@endcan
</div>
@else
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600">
<tr>
<th scope="col" class="text-start px-4 py-3">
<button type="button" wire:click="sort('title')" class="font-semibold">{{ __('الصفحة') }}</button>
</th>
<th scope="col" class="text-start px-4 py-3">{{ __('الرابط') }}</th>
<th scope="col" class="text-start px-4 py-3">{{ __('العناصر') }}</th>
<th scope="col" class="text-start px-4 py-3">{{ __('الحالة') }}</th>
<th scope="col" class="text-start px-4 py-3">{{ __('إجراءات') }}</th>
</tr>
</thead>
<tbody class="divide-y">
@foreach ($pages as $page)
<tr wire:key="page-{{ $page->id }}">
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<span class="font-medium">{{ $page->title ?: $page->title_en ?: $page->slug }}</span>
@if ($page->is_homepage)
<span class="text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded-full">{{ __('الرئيسية') }}</span>
@endif
</div>
</td>
<td class="px-4 py-3 font-mono text-xs text-gray-500" dir="ltr">/{{ $page->slug }}</td>
<td class="px-4 py-3 text-gray-600">{{ $page->all_blocks_count }}</td>
<td class="px-4 py-3">
<span class="text-xs px-2 py-1 rounded-full {{ $page->is_published ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600' }}">
{{ $page->is_published ? __('منشورة') : __('مسودة') }}
</span>
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap items-center gap-3">
@can('settings.manage')
<a href="{{ route('website.manage.builder', $page->id) }}" wire:navigate
class="text-primary-600 font-medium">{{ __('تصميم') }}</a>
<button type="button" wire:click="edit({{ $page->id }})" class="text-gray-600">{{ __('تعديل') }}</button>
<button type="button" wire:click="togglePublish({{ $page->id }})" class="text-gray-600">
{{ $page->is_published ? __('إلغاء النشر') : __('نشر') }}
</button>
<button type="button" wire:click="duplicate({{ $page->id }})" class="text-gray-600">{{ __('نسخ') }}</button>
@unless ($page->is_homepage)
<button type="button" wire:click="makeHomepage({{ $page->id }})" class="text-gray-600">{{ __('تعيين كرئيسية') }}</button>
<button type="button" wire:click="delete({{ $page->id }})"
wire:confirm="{{ __('هل أنت متأكد من حذف هذه الصفحة؟') }}"
class="text-red-600">{{ __('حذف') }}</button>
@endunless
@endcan
<a href="{{ $page->url() }}" target="_blank" rel="noopener" class="text-gray-500">{{ __('عرض') }}</a>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
@endif
</div>
{{ $pages->links() }}
</div>
<p class="ec-muted text-center py-12">{{ $block->get('empty_message') ?: __('لا توجد بيانات لعرضها حاليًا') }}</p>
@php $btns = collect($block->get('buttons') ?: []); @endphp
@if ($btns->isNotEmpty())
<div class="flex flex-wrap justify-center gap-3 mt-10">
@foreach ($btns as $i => $btn)
@php $btnHref = safe_url(data_get($btn, 'url')); @endphp
@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') }}
</a>
@endforeach
</div>
@endif
{{-- 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>
@endif
@if ($block->get('subtitle'))
<p class="ec-muted mt-3 text-lg">{{ $block->get('subtitle') }}</p>
@endif
</header>
@endif
{{--
Wraps every block. Owns presentation concerns (background, spacing,
animation, responsive visibility) so individual block partials only
describe their own content.
--}}
@php
$style = $block->style ?? [];
$get = fn ($k, $d = null) => data_get($style, $k, $d);
$padding = match ($get('padding_y', 'md')) {
'none' => 'py-0',
'xs' => 'py-4',
'sm' => 'py-8',
'md' => 'py-16',
'lg' => 'py-24',
'xl' => 'py-32',
'2xl' => 'py-40',
default => 'py-16',
};
$visibility = collect([
$get('hide_on_mobile') ? 'hidden md:block' : null,
$get('hide_on_desktop') ? 'md:hidden' : null,
])->filter()->implode(' ');
$bgType = $get('bg_type', 'none');
$animation = $get('animation', 'none');
$anchor = $get('anchor') ?: 'block-' . $block->uuid;
$inline = [];
if ($bgType === 'solid' && $get('bg_color')) {
$inline[] = 'background-color:' . $get('bg_color');
}
if ($bgType === 'gradient' && $get('bg_gradient_from')) {
$inline[] = sprintf(
'background-image:linear-gradient(%ddeg, %s, %s)',
(int) $get('bg_gradient_angle', 180),
$get('bg_gradient_from'),
$get('bg_gradient_to', $get('bg_gradient_from')),
);
}
if ($get('text_color')) {
$inline[] = 'color:' . $get('text_color');
}
@endphp
<section id="{{ $anchor }}"
data-block-type="{{ $block->type }}"
data-block-variant="{{ $block->variant }}"
@if ($animation && $animation !== 'none')
data-animation="{{ $animation }}"
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', '') }}"
@if ($inline) style="{{ implode(';', $inline) }}" @endif>
@if ($bgType === 'image' && $get('bg_image'))
<div class="absolute inset-0 -z-10 bg-cover bg-center"
style="background-image:url('{{ $get('bg_image') }}')"
@if ($get('bg_fixed')) data-parallax="true" @endif
aria-hidden="true"></div>
<div class="absolute inset-0 -z-10"
style="background-color:{{ $get('bg_overlay_color', '#000') }};opacity:{{ (int) $get('bg_overlay_opacity', 50) / 100 }}"
aria-hidden="true"></div>
@endif
@if ($bgType === 'video' && $get('bg_video'))
<video class="absolute inset-0 -z-10 h-full w-full object-cover"
autoplay muted loop playsinline aria-hidden="true"
@if ($get('bg_image')) poster="{{ $get('bg_image') }}" @endif>
<source src="{{ $get('bg_video') }}">
</video>
<div class="absolute inset-0 -z-10"
style="background-color:{{ $get('bg_overlay_color', '#000') }};opacity:{{ (int) $get('bg_overlay_opacity', 50) / 100 }}"
aria-hidden="true"></div>
@endif
{{ $content }}
</section>
@php
$single = $block->get('single_open', true);
$itemClass = match ($variant) {
'separated' => 'ec-surface rounded-xl shadow-sm mb-3',
'minimal' => 'border-b',
default => 'border rounded-xl mb-3',
};
@endphp
<div class="{{ $variant === 'two_column' ? 'max-w-6xl' : 'max-w-3xl' }} mx-auto px-4 sm:px-6 lg:px-8">
@if ($block->get('title') || $block->get('subtitle'))
<header class="text-center mb-10">
@if ($block->get('title'))
<h2 class="ec-heading text-3xl sm:text-4xl font-bold">{{ $block->get('title') }}</h2>
@endif
@if ($block->get('subtitle'))
<p class="ec-muted mt-3">{{ $block->get('subtitle') }}</p>
@endif
</header>
@endif
<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'));
@endphp
<div class="{{ $itemClass }} break-inside-avoid overflow-hidden">
<h3>
<button type="button"
x-on:click="open = (open === {{ $i }} ? null : {{ $i }})"
x-bind:aria-expanded="open === {{ $i }} ? 'true' : 'false'"
aria-controls="faq-panel-{{ $block->id }}-{{ $i }}"
class="w-full flex items-center justify-between gap-4 text-start p-5 font-semibold">
<span>{{ $q }}</span>
<svg class="w-5 h-5 shrink-0 transition-transform" x-bind:class="open === {{ $i }} && '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>
</h3>
<div id="faq-panel-{{ $block->id }}-{{ $i }}" x-show="open === {{ $i }}" x-collapse x-cloak>
<div class="ec-muted ec-prose px-5 pb-5">{!! clean_html($a) !!}</div>
</div>
</div>
@endforeach
</div>
</div>
@php
$features = collect($block->get('features') ?: []);
$stores = collect([
['url' => $block->get('ios_url'), 'badge' => $block->get('ios_badge'), 'label' => __('App Store'), 'sub' => __('حمّل من')],
['url' => $block->get('android_url'), 'badge' => $block->get('android_badge'), 'label' => __('Google Play'), 'sub' => __('احصل عليه من')],
['url' => $block->get('huawei_url'), 'badge' => $block->get('huawei_badge'), 'label' => __('AppGallery'), 'sub' => __('احصل عليه من')],
])->filter(fn ($s) => filled($s['url']));
$split = in_array($variant, ['split', 'floating_mockup'], true);
@endphp
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="{{ $split ? 'grid md:grid-cols-2 gap-12 items-center' : 'text-center max-w-3xl mx-auto' }}">
<div class="flex flex-col gap-5 {{ $split ? 'text-start' : 'items-center' }}">
@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>
@if ($block->get('description'))
<div class="ec-prose ec-muted leading-relaxed">{!! clean_html($block->get('description')) !!}</div>
@endif
@if ($features->isNotEmpty())
<ul class="grid sm:grid-cols-2 gap-4 mt-2 w-full">
@foreach ($features as $i => $f)
<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>
@endif
</div>
</li>
@endforeach
</ul>
@endif
@if ($stores->isNotEmpty())
<div class="flex flex-wrap gap-3 mt-3 {{ $split ? '' : 'justify-center' }}">
@foreach ($stores as $store)
@php $storeHref = safe_url($store['url']); @endphp
@continue (! $storeHref)
<a href="{{ $storeHref }}" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-3 transition hover:-translate-y-0.5 {{ $store['badge'] ? '' : 'rounded-xl border px-5 py-3' }}"
aria-label="{{ $store['sub'] }} {{ $store['label'] }}">
@if ($store['badge'])
{{-- Official badge artwork supplied by the academy. --}}
<img src="{{ $store['badge'] }}" alt="{{ $store['label'] }}" class="h-12 w-auto" loading="lazy">
@else
<x-website.icon name="arrow-down-tray" class="w-6 h-6" />
<span class="text-start leading-tight">
<span class="block text-xs opacity-70">{{ $store['sub'] }}</span>
<span class="block font-semibold">{{ $store['label'] }}</span>
</span>
@endif
</a>
@endforeach
</div>
@endif
</div>
@if ($block->get('screenshot'))
<div class="{{ $split ? '' : 'mt-10' }} flex justify-center">
<img src="{{ $block->get('screenshot') }}" alt="{{ $block->get('title') }}" loading="lazy"
class="w-full max-w-xs {{ $variant === 'floating_mockup' ? 'ec-float drop-shadow-2xl' : 'rounded-2xl' }}">
</div>
@endif
</div>
</div>
@php
$cols = ['1' => 'md:grid-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';
$cardClass = match ($variant) {
'bordered' => 'border rounded-2xl p-7',
'glass' => 'rounded-2xl p-7 backdrop-blur bg-white/10 border border-white/20',
'minimal' => 'p-2',
'numbered' => 'rounded-2xl p-7 ec-surface relative',
default => 'ec-surface rounded-2xl p-7 shadow-sm',
};
$iconInline = $variant === 'icon_start';
@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-3xl mx-auto mb-12">
@if ($block->get('title'))
<h2 class="ec-heading text-3xl sm:text-4xl font-bold">{{ $block->get('title') }}</h2>
@endif
@if ($block->get('subtitle'))
<p class="ec-muted mt-4 text-lg">{{ $block->get('subtitle') }}</p>
@endif
</header>
@endif
<div class="grid grid-cols-1 {{ $cols }} gap-6">
@foreach ($items as $i => $item)
@php $url = safe_url(data_get($item, 'url')); $tag = $url ? 'a' : 'div'; @endphp
<{{ $tag }} @if ($url) href="{{ $url }}" @endif
class="ec-stagger-item {{ $cardClass }} flex {{ $iconInline ? 'flex-row gap-4' : 'flex-col gap-4' }} {{ $url ? 'transition hover:-translate-y-1' : '' }}"
@if (data_get($item, 'accent')) style="--card-accent:{{ data_get($item, 'accent') }}" @endif>
@if ($variant === 'numbered')
<span class="ec-accent text-5xl font-extrabold opacity-25 leading-none">{{ str_pad($i + 1, 2, '0', STR_PAD_LEFT) }}</span>
@elseif (data_get($item, 'image'))
<img src="{{ data_get($item, 'image') }}" alt="" loading="lazy" class="w-full h-44 object-cover rounded-xl">
@elseif (data_get($item, 'icon'))
<span class="ec-accent shrink-0 inline-flex items-center justify-center w-12 h-12 rounded-xl">
<x-website.icon :name="data_get($item, 'icon')" class="w-6 h-6" />
</span>
@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>
@endif
@if (data_get($item, 'body'))
<div class="ec-muted leading-relaxed">{!! clean_html($block->get("items.{$i}.body") ?: data_get($item, 'body')) !!}</div>
@endif
</div>
</{{ $tag }}>
@endforeach
</div>
</div>
@php
$count = ['two' => 2, 'two_wide_start' => 2, 'two_wide_end' => 2, 'three' => 3, 'four' => 4][$variant] ?? 2;
$grid = match ($variant) {
'two_wide_start' => 'md:grid-cols-3 [&>*:first-child]:md:col-span-2',
'two_wide_end' => 'md:grid-cols-3 [&>*:last-child]:md:col-span-2',
'three' => 'md:grid-cols-2 lg:grid-cols-3',
'four' => 'sm:grid-cols-2 lg:grid-cols-4',
default => 'md:grid-cols-2',
};
$gap = ['none' => 'gap-0', 'sm' => 'gap-4', 'md' => 'gap-8', 'lg' => 'gap-12'][$block->get('gap', 'md')] ?? 'gap-8';
$valign = ['start' => 'items-start', 'center' => 'items-center', 'stretch' => 'items-stretch'][$block->get('vertical_align', 'stretch')] ?? 'items-stretch';
$reverse = $block->get('reverse_on_mobile') ? 'flex-col-reverse md:flex-row' : '';
@endphp
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 {{ $grid }} {{ $gap }} {{ $valign }} {{ $reverse }}">
@for ($i = 1; $i <= $count; $i++)
<div class="ec-column min-w-0">
{!! $renderer->renderChildren($block, 'col' . $i, $ctx) !!}
</div>
@endfor
</div>
</div>
@php $split = $variant === 'split_info'; @endphp
<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('title'))
<h2 class="ec-heading text-3xl sm:text-4xl font-bold">{{ $block->get('title') }}</h2>
@endif
@if ($block->get('description'))
<p class="ec-muted text-lg leading-relaxed">{{ $block->get('description') }}</p>
@endif
@if ($split)
{!! $renderer->renderChildren($block, 'default', $ctx) !!}
@endif
</div>
<div class="{{ $variant === 'boxed' ? 'ec-surface rounded-2xl p-8 shadow-sm' : '' }}">
@livewire('public.website-contact-form', [
'blockId' => $block->id,
'showSubject' => (bool) $block->get('show_subject', true),
'showPhone' => (bool) $block->get('show_phone', true),
'requirePhone' => (bool) $block->get('require_phone', false),
'submitLabel' => $block->get('submit_label'),
'successMessage' => $block->get('success_message'),
], key('contact-form-' . $block->id))
</div>
</div>
</div>
@php
$buttons = collect($block->get('buttons') ?: []);
$align = $block->get('align', 'center');
$alignClass = ['start' => 'text-start items-start', 'center' => 'text-center items-center', 'end' => 'text-end items-end'][$align] ?? 'text-center items-center';
$shell = match ($variant) {
'card', 'boxed' => 'ec-surface rounded-3xl p-10 sm:p-14 shadow-sm',
'gradient' => 'ec-gradient rounded-3xl p-10 sm:p-14',
'image_bg' => 'relative rounded-3xl p-10 sm:p-14 overflow-hidden',
default => '',
};
@endphp
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="{{ $shell }}">
@if ($variant === 'image_bg' && $block->get('image'))
<img src="{{ $block->get('image') }}" alt="" class="absolute inset-0 -z-10 h-full w-full object-cover" aria-hidden="true">
<div class="absolute inset-0 -z-10 bg-black/55" aria-hidden="true"></div>
@endif
<div class="{{ $variant === 'split' ? 'grid md:grid-cols-[1fr_auto] gap-8 items-center' : 'flex flex-col gap-5 ' . $alignClass }}">
<div class="flex flex-col gap-3 {{ $variant === 'split' ? 'text-start' : '' }}">
<h2 class="ec-heading text-2xl sm:text-4xl font-bold">{{ $block->get('title') }}</h2>
@if ($block->get('description'))
<p class="ec-muted text-lg max-w-2xl">{{ $block->get('description') }}</p>
@endif
</div>
@if ($buttons->isNotEmpty())
<div class="flex flex-wrap gap-3 {{ $align === 'center' && $variant !== 'split' ? 'justify-center' : '' }}">
@foreach ($buttons as $i => $btn)
@php $btnHref = safe_url(data_get($btn, 'url')); @endphp
@continue (! $btnHref)
<a href="{{ $btnHref }}"
class="ec-btn ec-btn--{{ data_get($btn, 'style', 'primary') }} inline-flex items-center gap-2 rounded-full px-8 py-3.5 font-semibold transition">
@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') }}
</a>
@endforeach
</div>
@endif
</div>
</div>
</div>
<div class="{{ $block->get('full_width') ? '' : 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8' }}">
{{-- Sanitised on output: a pasted snippet must not be able to inject script. --}}
{!! clean_html($block->get('html')) !!}
</div>
@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';
$ar = app()->getLocale() === 'ar';
@endphp
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@include('website.blocks._header')
@if ($items->isEmpty())
@include('website.blocks._empty')
@else
<div class="grid grid-cols-2 {{ $cols }} gap-5">
@foreach ($items as $activity)
@php
$name = $ar ? ($activity->name_ar ?? $activity->name) : ($activity->name ?? $activity->name_ar);
$img = $activity->image_path ?? $activity->photo_path ?? null;
@endphp
<article class="ec-stagger-item group relative overflow-hidden rounded-2xl {{ $variant === 'image_overlay' ? 'aspect-[4/5]' : 'ec-surface shadow-sm' }}">
@if ($block->get('show_image', true) && $img)
<img src="{{ $img }}" alt="{{ $name }}" loading="lazy"
class="{{ $variant === 'image_overlay' ? 'absolute inset-0 h-full w-full' : 'w-full aspect-[4/3]' }} object-cover transition duration-500 group-hover:scale-105">
@if ($variant === 'image_overlay')
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" aria-hidden="true"></div>
@endif
@elseif ($activity->icon ?? null)
<div class="p-6 flex justify-center"><x-website.icon :name="$activity->icon" class="w-10 h-10 ec-accent" /></div>
@endif
<div class="{{ $variant === 'image_overlay' ? 'absolute inset-x-0 bottom-0 p-5 text-white' : 'p-5' }}">
<h3 class="font-semibold text-lg">{{ $name }}</h3>
@if ($block->get('show_description', true) && ($activity->description_ar ?? $activity->description ?? null))
<p class="{{ $variant === 'image_overlay' ? 'text-white/80' : 'ec-muted' }} text-sm mt-1 line-clamp-2">
{{ $ar ? ($activity->description_ar ?? $activity->description) : ($activity->description ?? $activity->description_ar) }}
</p>
@endif
</div>
</article>
@endforeach
</div>
@endif
@include('website.blocks._footer_buttons')
</div>
@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';
$ar = app()->getLocale() === 'ar';
@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')
@if ($block->get('show_search'))
<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"
aria-label="{{ __('بحث في الفروع') }}">
</div>
@endif
@if ($items->isEmpty())
@include('website.blocks._empty')
@else
<div class="grid grid-cols-1 {{ $cols }} gap-6">
@foreach ($items as $branch)
@php
$name = $ar ? ($branch->name_ar ?? $branch->name) : ($branch->name ?? $branch->name_ar);
$loc = $branch->address ?? $branch->location ?? null;
$photo = $branch->photo_path ?? 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>
@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
@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
</div>
</article>
@endforeach
</div>
@endif
@include('website.blocks._footer_buttons')
</div>
@php $ar = app()->getLocale() === 'ar'; @endphp
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@include('website.blocks._header')
@if ($items->isEmpty())
@include('website.blocks._empty')
@else
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($items as $event)
@php $title = $ar ? ($event->title ?? $event->title_en) : ($event->title_en ?? $event->title); @endphp
<article class="ec-stagger-item ec-surface rounded-2xl overflow-hidden shadow-sm flex flex-col">
@if ($block->get('show_image', true) && ($event->cover_path ?? null))
<img src="{{ $event->cover_path }}" alt="{{ $title }}" loading="lazy" class="w-full aspect-[16/9] object-cover">
@endif
<div class="p-6 flex flex-col gap-2 flex-1">
@if ($event->starts_at ?? null)
<time class="ec-accent text-sm font-semibold" datetime="{{ $event->starts_at->toDateString() }}">
{{ $event->starts_at->translatedFormat('j F Y') }}
</time>
@endif
<h3 class="ec-heading font-semibold text-lg">{{ $title }}</h3>
@if ($block->get('show_location', true) && ($event->location_address ?? null))
<p class="ec-muted text-sm flex items-start gap-2">
<x-website.icon name="map-pin" class="w-4 h-4 mt-0.5 shrink-0" />
<span>{{ $event->location_address }}</span>
</p>
@endif
@if ($block->get('show_countdown') && ($event->starts_at ?? null) && $event->starts_at->isFuture())
<p class="ec-muted text-sm" data-countdown="{{ $event->starts_at->toIso8601String() }}"></p>
@endif
@if ($block->get('show_register_button', true))
<a href="{{ route('website.events.show', $event->slug ?? $event) }}"
class="ec-btn ec-btn--primary mt-auto text-center rounded-full px-5 py-2.5 font-semibold transition">
{{ __('سجّل الآن') }}
</a>
@endif
</div>
</article>
@endforeach
</div>
@endif
@include('website.blocks._footer_buttons')
</div>
@php $ar = app()->getLocale() === 'ar'; @endphp
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@include('website.blocks._header')
@if ($items->isEmpty())
@include('website.blocks._empty')
@else
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($items as $n)
@php $title = $ar ? ($n->title ?? $n->title_en) : ($n->title_en ?? $n->title); @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_image', true) && ($n->image_path ?? null))
<img src="{{ $n->image_path }}" alt="{{ $title }}" loading="lazy" class="w-full aspect-[16/9] object-cover">
@endif
<div class="p-6 flex flex-col gap-2 flex-1">
@if ($block->get('show_date', true) && ($n->published_at ?? null))
<time class="ec-muted text-xs" datetime="{{ $n->published_at->toDateString() }}">{{ $n->published_at->translatedFormat('j F Y') }}</time>
@endif
<h3 class="ec-heading font-semibold text-lg leading-snug">{{ $title }}</h3>
@if ($block->get('show_excerpt', true))
<p class="ec-muted text-sm line-clamp-3">{{ $ar ? ($n->excerpt ?? '') : ($n->excerpt_en ?? $n->excerpt ?? '') }}</p>
@endif
@if ($block->get('link_items', true))
<a href="{{ route('website.news.show', $n) }}" class="ec-accent text-sm font-semibold mt-auto pt-3">{{ __('اقرأ المزيد') }} →</a>
@endif
</div>
</article>
@endforeach
</div>
@endif
@include('website.blocks._footer_buttons')
</div>
@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';
$ar = app()->getLocale() === 'ar';
@endphp
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@include('website.blocks._header')
@if ($items->isEmpty())
@include('website.blocks._empty')
@else
<div class="grid grid-cols-1 {{ $cols }} gap-6">
@foreach ($items as $program)
@php $name = $ar ? ($program->name_ar ?? $program->name) : ($program->name ?? $program->name_ar); @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_image', true) && ($program->image_path ?? null))
<img src="{{ $program->image_path }}" alt="{{ $name }}" loading="lazy" class="w-full aspect-[3/2] object-cover">
@endif
<div class="p-6 flex flex-col gap-2 flex-1">
<h3 class="ec-heading text-xl font-semibold">{{ $name }}</h3>
@if ($ar ? ($program->description_ar ?? null) : ($program->description ?? null))
<p class="ec-muted text-sm line-clamp-3">{{ $ar ? $program->description_ar : $program->description }}</p>
@endif
<dl class="flex flex-wrap gap-x-5 gap-y-1 mt-2 text-sm ec-muted">
@if ($block->get('show_age_range', true) && (($program->min_age ?? null) || ($program->max_age ?? null)))
<div class="flex items-center gap-1.5">
<x-website.icon name="users" class="w-4 h-4" />
<span dir="ltr">{{ $program->min_age ?? '?' }}–{{ $program->max_age ?? '?' }}</span>
<span>{{ __('سنة') }}</span>
</div>
@endif
@if ($block->get('show_duration') && ($program->duration_weeks ?? null))
<div class="flex items-center gap-1.5">
<x-website.icon name="calendar" class="w-4 h-4" />
<span>{{ $program->duration_weeks }} {{ __('أسبوع') }}</span>
</div>
@endif
</dl>
@if ($block->get('show_price') && ($program->base_price ?? null))
<p class="ec-accent font-bold mt-auto pt-3" dir="ltr">{{ format_money($program->base_price) }}</p>
@endif
</div>
</article>
@endforeach
</div>
@endif
@include('website.blocks._footer_buttons')
</div>
@php
$ar = app()->getLocale() === 'ar';
$days = [__('الأحد'), __('الإثنين'), __('الثلاثاء'), __('الأربعاء'), __('الخميس'), __('الجمعة'), __('السبت')];
$grouped = $items->groupBy(fn ($g) => $g->branch_name ?? optional($g->branch)->name_ar ?? __('عام'));
$lightbox = $block->get('enable_lightbox', true);
@endphp
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"
@if ($lightbox) x-data="{ open: false, current: '' }" @endif>
@include('website.blocks._header')
@if ($items->isEmpty())
@include('website.blocks._empty')
@elseif ($variant === 'cards')
{{-- Image-per-session layout: academies often publish a designed card per group. --}}
@foreach ($grouped as $branchName => $groups)
<section class="mb-12">
<h3 class="ec-heading text-xl font-bold mb-5">{{ $branchName }}</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5">
@foreach ($groups as $g)
<figure class="ec-stagger-item">
@if ($g->image_path ?? null)
<img src="{{ $g->image_path }}" alt="{{ $g->name_ar ?? $g->name }}" loading="lazy"
class="w-full aspect-square object-cover rounded-xl {{ $lightbox ? 'cursor-zoom-in' : '' }}"
@if ($lightbox) x-on:click="current = @js($g->image_path); open = true" @endif>
@endif
<figcaption class="mt-2 text-center text-sm font-medium">{{ $g->name_ar ?? $g->name }}</figcaption>
</figure>
@endforeach
</div>
</section>
@endforeach
@else
@foreach ($grouped as $branchName => $groups)
<section class="mb-10">
<h3 class="ec-heading text-xl font-bold mb-4">{{ $branchName }}</h3>
<div class="overflow-x-auto">
<table class="w-full text-start border-collapse">
<thead>
<tr class="ec-muted text-sm border-b">
<th scope="col" class="py-3 pe-4 text-start font-semibold">{{ __('المجموعة') }}</th>
@if ($block->get('show_age_groups', true))
<th scope="col" class="py-3 pe-4 text-start font-semibold">{{ __('الفئة') }}</th>
@endif
@if ($block->get('show_days', true))
<th scope="col" class="py-3 pe-4 text-start font-semibold">{{ __('الأيام') }}</th>
@endif
@if ($block->get('show_times', true))
<th scope="col" class="py-3 text-start font-semibold">{{ __('الوقت') }}</th>
@endif
</tr>
</thead>
<tbody>
@foreach ($groups as $g)
<tr class="border-b last:border-0">
<td class="py-3 pe-4 font-medium">{{ $g->name_ar ?? $g->name }}</td>
@if ($block->get('show_age_groups', true))
<td class="py-3 pe-4 ec-muted text-sm" dir="ltr">{{ $g->age_range ?? '—' }}</td>
@endif
@if ($block->get('show_days', true))
<td class="py-3 pe-4 ec-muted text-sm">
{{ collect($g->days ?? [])->map(fn ($d) => $days[(int) $d] ?? $d)->implode('، ') ?: '—' }}
</td>
@endif
@if ($block->get('show_times', true))
<td class="py-3 ec-muted text-sm" dir="ltr">
{{ $g->start_time ?? '' }}{{ ($g->start_time ?? null) && ($g->end_time ?? null) ? ' – ' : '' }}{{ $g->end_time ?? '' }}
</td>
@endif
</tr>
@endforeach
</tbody>
</table>
</div>
</section>
@endforeach
@endif
@include('website.blocks._footer_buttons')
@if ($lightbox)
<div x-show="open" x-cloak x-on:keydown.escape.window="open = false" x-on:click="open = false"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4"
role="dialog" aria-modal="true" aria-label="{{ __('عرض الجدول') }}">
<img x-bind:src="current" alt="" class="max-h-[90vh] max-w-full rounded-lg" x-on:click.stop>
</div>
@endif
</div>
@php $ar = app()->getLocale() === 'ar'; @endphp
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@include('website.blocks._header')
@if ($items->isEmpty())
@include('website.blocks._empty')
@else
<div class="{{ $variant === 'masonry' ? 'columns-1 md:columns-2 lg:columns-3 gap-6 [&>*]:mb-6' : ($variant === 'carousel' ? 'flex gap-6 overflow-x-auto snap-x snap-mandatory pb-4' : 'grid gap-6 sm:grid-cols-2 lg:grid-cols-3') }}">
@foreach ($items as $t)
@php $content = $ar ? ($t->content ?? $t->content_en) : ($t->content_en ?? $t->content); @endphp
<figure class="ec-stagger-item ec-surface rounded-2xl p-7 shadow-sm break-inside-avoid {{ $variant === 'carousel' ? 'snap-start shrink-0 w-80' : '' }}">
@if ($block->get('show_rating', true) && ($t->rating ?? null))
<div class="flex gap-0.5 mb-3 ec-accent" dir="ltr" aria-label="{{ $t->rating }}/5">
@for ($s = 1; $s <= 5; $s++)
<svg class="w-4 h-4 {{ $s <= $t->rating ? 'fill-current' : 'opacity-25 fill-current' }}" viewBox="0 0 20 20" aria-hidden="true">
<path d="M10 1l2.6 5.3 5.9.9-4.2 4.1 1 5.8L10 14.4 4.7 17.1l1-5.8L1.5 7.2l5.9-.9z"/>
</svg>
@endfor
</div>
@endif
<blockquote class="ec-muted leading-relaxed">{{ $content }}</blockquote>
<figcaption class="flex items-center gap-3 mt-5">
@if ($block->get('show_avatar', true) && ($t->avatar_path ?? null))
<img src="{{ $t->avatar_path }}" alt="" loading="lazy" class="w-11 h-11 rounded-full object-cover">
@endif
<div class="min-w-0">
<p class="font-semibold text-sm">{{ $t->author_name ?? $t->name ?? '' }}</p>
@if ($t->author_role ?? null)
<p class="ec-muted text-xs">{{ $t->author_role }}</p>
@endif
</div>
</figcaption>
</figure>
@endforeach
</div>
@endif
@include('website.blocks._footer_buttons')
</div>
@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', '4')] ?? 'sm:grid-cols-2 lg:grid-cols-4';
$round = $variant === 'circles';
@endphp
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@include('website.blocks._header')
@if ($items->isEmpty())
@include('website.blocks._empty')
@else
<div class="grid grid-cols-2 {{ $cols }} gap-6">
@foreach ($items as $trainer)
@php $name = $trainer->full_name ?? $trainer->name ?? ''; @endphp
<article class="ec-stagger-item flex flex-col {{ $round ? 'items-center text-center' : 'ec-surface rounded-2xl overflow-hidden shadow-sm' }} gap-3">
@if ($block->get('show_image', true))
<img src="{{ $trainer->photo_path ?? asset('images/avatar-placeholder.png') }}" alt="{{ $name }}" loading="lazy"
class="{{ $round ? 'w-32 h-32 rounded-full' : 'w-full aspect-square' }} object-cover">
@endif
<div class="{{ $round ? '' : 'px-5 pb-5' }} flex flex-col gap-1">
<h3 class="font-semibold">{{ $name }}</h3>
@if ($block->get('show_role', true) && ($trainer->job_title ?? $trainer->role ?? null))
<p class="ec-accent text-sm">{{ $trainer->job_title ?? $trainer->role }}</p>
@endif
@if ($block->get('show_bio') && ($trainer->bio ?? null))
<p class="ec-muted text-sm line-clamp-3 mt-1">{{ $trainer->bio }}</p>
@endif
</div>
</article>
@endforeach
</div>
@endif
@include('website.blocks._footer_buttons')
</div>
@php
$color = $block->get('color') ?: 'currentColor';
$h = ['sm' => 40, 'md' => 70, 'lg' => 110][$block->get('size', 'md')] ?? 70;
$flip = $block->get('flip') ? 'rotate-180' : '';
@endphp
@if ($variant === 'line')
<div class="max-w-7xl mx-auto px-4"><hr class="border-t" style="border-color:{{ $color }}"></div>
@elseif ($variant === 'dots')
<div class="flex justify-center gap-2" aria-hidden="true">
@for ($i = 0; $i < 3; $i++)
<span class="w-2 h-2 rounded-full" style="background:{{ $color }}"></span>
@endfor
</div>
@else
<div class="w-full leading-none {{ $flip }}" aria-hidden="true">
<svg viewBox="0 0 1440 {{ $h }}" preserveAspectRatio="none" class="w-full" style="height:{{ $h }}px" fill="{{ $color }}">
@switch ($variant)
@case('wave')
<path d="M0,{{ $h * 0.5 }} C240,{{ $h }} 480,0 720,{{ $h * 0.5 }} C960,{{ $h }} 1200,0 1440,{{ $h * 0.5 }} L1440,{{ $h }} L0,{{ $h }} Z"/>
@break
@case('angle')
<path d="M0,{{ $h }} L1440,0 L1440,{{ $h }} Z"/>
@break
@case('curve')
<path d="M0,{{ $h }} Q720,-{{ $h * 0.4 }} 1440,{{ $h }} Z"/>
@break
@case('zigzag')
<path d="M0,{{ $h }} L180,0 L360,{{ $h }} L540,0 L720,{{ $h }} L900,0 L1080,{{ $h }} L1260,0 L1440,{{ $h }} Z"/>
@break
@endswitch
</svg>
</div>
@endif
@php
$ratio = ['16:9' => 'aspect-video', '4:3' => 'aspect-[4/3]', '1:1' => 'aspect-square', 'auto' => 'min-h-96'][$block->get('ratio', '16:9')] ?? 'aspect-video';
$src = website_embed_url($block->get('provider'), $block->get('url'));
@endphp
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
@if ($block->get('title'))
<h2 class="ec-heading text-2xl font-bold text-center mb-6">{{ $block->get('title') }}</h2>
@endif
@if ($src)
<div class="{{ $ratio }} w-full overflow-hidden rounded-2xl">
<iframe src="{{ $src }}" class="w-full h-full border-0" loading="lazy" allowfullscreen
title="{{ $block->get('title') ?: $block->get('provider') }}"
referrerpolicy="strict-origin-when-cross-origin"></iframe>
</div>
@else
<p class="ec-muted text-center">{{ __('رابط التضمين غير صالح') }}</p>
@endif
</div>
@php
$cols = ['2' => 'sm:grid-cols-2', '3' => 'sm:grid-cols-2 md:grid-cols-3', '4' => 'sm:grid-cols-2 md:grid-cols-4', '5' => 'sm:grid-cols-3 md:grid-cols-5', '6' => 'sm:grid-cols-3 md:grid-cols-6'][$block->get('columns', '4')] ?? 'sm:grid-cols-2 md:grid-cols-4';
$aspect = ['square' => 'aspect-square', 'landscape' => 'aspect-[4/3]', 'portrait' => 'aspect-[3/4]', 'auto' => ''][$block->get('aspect', 'square')] ?? 'aspect-square';
$lightbox = $block->get('enable_lightbox', true);
$captions = $block->get('show_captions');
$src = fn ($img) => is_array($img) ? (data_get($img, 'url') ?: data_get($img, 'path')) : $img;
$cap = fn ($img) => is_array($img) ? (data_get($img, 'caption') ?: data_get($img, 'alt_text')) : null;
@endphp
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8" @if ($lightbox) x-data="{ open: false, current: '' }" @endif>
@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>
@endif
@if ($block->get('subtitle'))
<p class="ec-muted mt-3">{{ $block->get('subtitle') }}</p>
@endif
</header>
@endif
@if ($items->isEmpty())
<p class="ec-muted text-center py-10">{{ $block->get('empty_message') ?: __('لا توجد صور بعد') }}</p>
@elseif ($variant === 'masonry')
<div class="columns-2 md:columns-3 lg:columns-4 gap-4 [&>*]:mb-4">
@foreach ($items as $img)
<figure class="ec-stagger-item break-inside-avoid">
<img src="{{ $src($img) }}" alt="{{ $cap($img) }}" loading="lazy"
class="w-full rounded-xl {{ $lightbox ? 'cursor-zoom-in' : '' }}"
@if ($lightbox) x-on:click="current = @js($src($img)); open = true" @endif>
@if ($captions && $cap($img))
<figcaption class="ec-muted text-sm mt-2">{{ $cap($img) }}</figcaption>
@endif
</figure>
@endforeach
</div>
@elseif (in_array($variant, ['carousel', 'filmstrip', 'marquee'], true))
<div class="{{ $variant === 'marquee' ? 'ec-marquee overflow-hidden' : 'overflow-x-auto snap-x snap-mandatory' }}">
<div class="flex gap-4 {{ $variant === 'marquee' ? 'ec-marquee-track' : '' }}">
@foreach ($items as $img)
<figure class="snap-start shrink-0 w-64 sm:w-80">
<img src="{{ $src($img) }}" alt="{{ $cap($img) }}" loading="lazy"
class="w-full {{ $aspect }} object-cover rounded-xl {{ $lightbox ? 'cursor-zoom-in' : '' }}"
@if ($lightbox) x-on:click="current = @js($src($img)); open = true" @endif>
@if ($captions && $cap($img))
<figcaption class="ec-muted text-sm mt-2">{{ $cap($img) }}</figcaption>
@endif
</figure>
@endforeach
</div>
</div>
@else
<div class="grid grid-cols-2 {{ $cols }} gap-4">
@foreach ($items as $img)
<figure class="ec-stagger-item group">
<img src="{{ $src($img) }}" alt="{{ $cap($img) }}" loading="lazy"
class="w-full {{ $aspect }} object-cover rounded-xl transition group-hover:scale-[1.02] {{ $lightbox ? 'cursor-zoom-in' : '' }}"
@if ($lightbox) x-on:click="current = @js($src($img)); open = true" @endif>
@if ($captions && $cap($img))
<figcaption class="ec-muted text-sm mt-2">{{ $cap($img) }}</figcaption>
@endif
</figure>
@endforeach
</div>
@endif
@if ($lightbox)
<div x-show="open" x-cloak x-on:keydown.escape.window="open = false" x-on:click="open = false"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4"
role="dialog" aria-modal="true" aria-label="{{ __('عرض الصورة') }}">
<button type="button" x-on:click="open = false"
class="absolute top-5 end-5 text-white/80 hover:text-white" aria-label="{{ __('إغلاق') }}">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
<img x-bind:src="current" alt="" class="max-h-[90vh] max-w-full rounded-lg" x-on:click.stop>
</div>
@endif
</div>
@php
$height = match ($block->get('height', 'three_quarter')) {
'auto' => 'min-h-0 py-20', 'half' => 'min-h-[50vh]',
'three_quarter' => 'min-h-[75vh]', 'full' => 'min-h-screen', default => 'min-h-[75vh]',
};
$align = $block->get('align', 'center');
$alignClass = ['start' => 'text-start items-start', 'center' => 'text-center items-center', 'end' => 'text-end items-end'][$align] ?? 'text-center items-center';
$buttons = collect($block->get('buttons') ?: []);
$products = collect($block->get('products') ?: []);
$slides = collect($block->get('slides') ?: []);
$split = in_array($variant, ['split_start', 'split_end'], true);
@endphp
<div class="relative {{ $height }} flex items-center">
@if ($variant === 'video_bg' && $block->get('video_url'))
<video class="absolute inset-0 -z-10 h-full w-full object-cover" autoplay muted loop playsinline aria-hidden="true">
<source src="{{ $block->get('video_url') }}">
</video>
<div class="absolute inset-0 -z-10 bg-black/50" aria-hidden="true"></div>
@endif
@if ($variant === 'slideshow' && $slides->isNotEmpty())
<div class="absolute inset-0 -z-10 ec-hero-slideshow" x-data="{ i: 0, n: {{ $slides->count() }} }"
x-init="setInterval(() => i = (i + 1) % n, 6000)">
@foreach ($slides as $idx => $slide)
<img src="{{ is_array($slide) ? ($slide['url'] ?? '') : $slide }}" alt=""
class="absolute inset-0 h-full w-full object-cover transition-opacity duration-1000"
x-bind:class="i === {{ $idx }} ? 'opacity-100' : 'opacity-0'">
@endforeach
</div>
<div class="absolute inset-0 -z-10 bg-black/45" aria-hidden="true"></div>
@endif
<div class="relative max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8">
<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>
@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>
@if ($block->get('subtitle'))
<p class="ec-muted text-lg sm:text-xl leading-relaxed max-w-2xl">{{ $block->get('subtitle') }}</p>
@endif
@if ($buttons->isNotEmpty())
<div class="flex flex-wrap gap-3 {{ $align === 'center' && ! $split ? 'justify-center' : '' }}">
@foreach ($buttons as $btn)
@php $btnHref = safe_url(data_get($btn, 'url')); @endphp
@continue (! $btnHref)
<a href="{{ $btnHref }}"
class="ec-btn ec-btn--{{ data_get($btn, 'style', 'primary') }} inline-flex items-center gap-2 rounded-full px-7 py-3 font-semibold transition">
@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') }}
</a>
@endforeach
</div>
@endif
</div>
@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">
@foreach ($products as $idx => $product)
@foreach (['front', 'back'] as $face)
@if (data_get($product, $face))
<img src="{{ data_get($product, $face) }}"
alt="{{ 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
@endforeach
@endforeach
</div>
<div class="flex items-center gap-3">
@foreach ($products as $idx => $product)
<button type="button" x-on:click="active = {{ $idx }}"
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>
@endforeach
</div>
<button type="button" x-on:click="back = !back" class="ec-muted text-sm underline">
<span x-show="!back">{{ __('عرض الخلف') }}</span>
<span x-show="back" x-cloak>{{ __('عرض الأمام') }}</span>
</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>
@endif
</div>
</div>
@if ($block->get('show_scroll_hint', true) && $variant !== 'centered_minimal')
<div class="absolute bottom-8 start-1/2 -translate-x-1/2 rtl:translate-x-1/2 ec-bounce-subtle" aria-hidden="true">
<svg class="w-6 h-6 opacity-70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"/>
</svg>
</div>
@endif
</div>
@php
$href = function ($item) {
$v = data_get($item, 'action_value') ?: data_get($item, 'value');
return match (data_get($item, 'action', 'none')) {
'tel' => 'tel:' . preg_replace('/\s+/', '', $v),
'mailto' => 'mailto:' . $v,
'whatsapp' => 'https://wa.me/' . preg_replace('/\D/', '', $v),
'maps' => 'https://www.google.com/maps/search/?api=1&query=' . urlencode($v),
'url' => safe_url($v),
default => null,
};
};
$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',
default => 'ec-surface rounded-2xl p-6 shadow-sm',
};
@endphp
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
@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">
@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">
<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="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') }}
</p>
</div>
</{{ $link ? 'a' : 'div' }}>
@endforeach
</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';
$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
@if ($variant === 'marquee')
<div class="ec-marquee overflow-hidden" style="--marquee-duration:{{ $speed }}">
<div class="ec-marquee-track flex items-center gap-12">
{{-- Duplicated once so the loop has no visible seam. --}}
@foreach ($items->concat($items) as $p)
<img src="{{ $logo($p) }}" alt="{{ $name($p) }}" loading="lazy"
class="h-12 w-auto object-contain shrink-0 transition {{ $gray }}">
@endforeach
</div>
</div>
@else
<div class="flex flex-wrap items-center justify-center gap-x-12 gap-y-8">
@foreach ($items as $p)
@php $url = safe_url(data_get($p, 'url')); @endphp
<{{ $url ? 'a' : 'div' }} @if ($url) href="{{ $url }}" target="_blank" rel="noopener noreferrer" @endif
class="ec-stagger-item {{ $variant === 'bordered' ? 'px-8 border-e last:border-e-0' : '' }}">
<img src="{{ $logo($p) }}" alt="{{ $name($p) }}" loading="lazy"
class="h-12 w-auto object-contain transition {{ $gray }}">
</{{ $url ? 'a' : 'div' }}>
@endforeach
</div>
@endif
</div>
@php
$height = ['sm' => 'h-64', 'md' => 'h-96', 'lg' => 'h-[32rem]'][$block->get('height', 'md')] ?? 'h-96';
$locations = $block->get('use_branch_data', true)
? $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;
@endphp
<div class="{{ $variant === 'full_width' ? '' : 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8' }}">
@if ($block->get('title'))
<h2 class="ec-heading text-3xl font-bold text-center mb-8">{{ $block->get('title') }}</h2>
@endif
<div class="{{ $variant === 'split' ? 'grid md:grid-cols-[20rem_1fr] gap-8' : '' }}">
@if ($variant === 'split')
<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>
</li>
@endforeach
</ul>
@endif
<div class="{{ $height }} w-full overflow-hidden {{ $variant === 'full_width' ? '' : 'rounded-2xl' }}">
@if ($query)
<iframe class="w-full h-full border-0" loading="lazy" allowfullscreen
referrerpolicy="no-referrer-when-downgrade"
title="{{ $block->get('title') ?: __('الموقع على الخريطة') }}"
src="https://www.google.com/maps?q={{ urlencode($query) }}&z={{ (int) $block->get('zoom', 13) }}&output=embed"></iframe>
@else
<div class="w-full h-full grid place-items-center ec-surface ec-muted">{{ __('لم يتم تحديد موقع') }}</div>
@endif
</div>
</div>
</div>
@php
$plans = collect($block->get('plans') ?: []);
$currency = $block->get('currency_label') ?: 'ج.م';
@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-3xl mx-auto mb-12">
@if ($block->get('title'))
<h2 class="ec-heading text-3xl sm:text-4xl font-bold">{{ $block->get('title') }}</h2>
@endif
@if ($block->get('subtitle'))
<p class="ec-muted mt-3 text-lg">{{ $block->get('subtitle') }}</p>
@endif
</header>
@endif
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-{{ min(max($plans->count(), 1), 4) }}">
@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>
@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>
@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>
</p>
@if ($features = collect(data_get($plan, 'features') ?: []))
<ul class="flex flex-col gap-2.5 flex-1">
@foreach ($features as $f)
@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>
</li>
@endforeach
</ul>
@endif
@php $planHref = safe_url(data_get($plan, 'button_url')); @endphp
@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') ?: __('اشترك الآن') }}
</a>
@endif
</div>
@endforeach
</div>
</div>
@php
$details = collect($block->get('details') ?: []);
$achievements = collect($block->get('achievements') ?: []);
$social = collect($block->get('social') ?: []);
$centered = in_array($variant, ['centered', 'quote_focus'], true);
@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>
@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' }}
{{ $variant === 'card' ? 'ec-surface rounded-3xl p-8 shadow-sm' : '' }}">
<figure class="{{ $centered ? 'w-40' : 'w-full' }} shrink-0">
<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">
</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>
@if ($block->get('quote'))
<blockquote class="ec-muted italic text-lg leading-relaxed border-s-4 ps-4">{{ $block->get('quote') }}</blockquote>
@endif
@if ($block->get('bio'))
<div class="ec-prose ec-muted leading-relaxed">{!! clean_html($block->get('bio')) !!}</div>
@endif
@if ($details->isNotEmpty())
<dl class="grid sm:grid-cols-2 gap-3">
@foreach ($details as $i => $d)
<div class="flex items-start gap-3">
@if (data_get($d, 'icon'))
<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>
</div>
</div>
@endforeach
</dl>
@endif
@if ($achievements->isNotEmpty())
<ul class="flex flex-col gap-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>
<span class="ec-muted leading-relaxed">{{ $block->get("achievements.{$i}.text") ?: data_get($a, 'text') }}</span>
</li>
@endforeach
</ul>
@endif
@if ($social->isNotEmpty())
<div class="flex gap-3 {{ $centered ? 'justify-center' : '' }}">
@foreach ($social as $s)
@php $socialHref = safe_url(data_get($s, 'url')); @endphp
@continue (! $socialHref)
<a href="{{ $socialHref }}" target="_blank" rel="noopener noreferrer"
class="ec-muted hover:opacity-70 transition" aria-label="{{ data_get($s, 'icon') }}">
<x-website.icon :name="data_get($s, 'icon')" class="w-5 h-5" />
</a>
@endforeach
</div>
@endif
</div>
</div>
</div>
@php
$sections = collect($block->get('sections') ?: []);
$width = match ($variant) {
'prose_narrow' => 'max-w-3xl', 'two_column' => 'max-w-6xl', 'with_toc' => 'max-w-6xl', default => 'max-w-4xl',
};
@endphp
<div class="{{ $width }} mx-auto px-4 sm:px-6 lg:px-8">
@if ($block->get('title'))
<h1 class="ec-heading text-3xl sm:text-4xl font-bold mb-3">{{ $block->get('title') }}</h1>
@endif
@if ($block->get('last_updated'))
<p class="ec-muted text-sm mb-8">{{ $block->get('last_updated') }}</p>
@endif
<div class="{{ $variant === 'with_toc' ? 'grid lg:grid-cols-[16rem_1fr] gap-12' : '' }}">
@if ($variant === 'with_toc' && $sections->isNotEmpty())
<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>
@endforeach
</ul>
</nav>
@endif
<div class="ec-prose {{ $variant === 'two_column' ? 'md:columns-2 md:gap-12' : '' }}">
@if ($block->get('body'))
{!! clean_html($block->get('body')) !!}
@endif
@foreach ($sections as $i => $s)
@php $anchor = 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>
@endif
{!! clean_html($block->get("sections.{$i}.body") ?: data_get($s, 'body')) !!}
</section>
@endforeach
</div>
</div>
</div>
@php
$width = match ($block->get('max_width', 'lg')) {
'sm' => 'max-w-3xl', 'md' => 'max-w-5xl', 'lg' => 'max-w-7xl',
'xl' => 'max-w-[90rem]', 'full' => 'max-w-none', default => 'max-w-7xl',
};
if ($variant === 'full_bleed') $width = 'max-w-none';
if ($variant === 'narrow') $width = 'max-w-3xl';
$align = $block->get('header_align', 'center');
$alignClass = ['start' => 'text-start', 'center' => 'text-center mx-auto', 'end' => 'text-end ms-auto'][$align] ?? 'text-center mx-auto';
@endphp
<div class="{{ $width }} mx-auto px-4 sm:px-6 lg:px-8">
@if ($block->get('eyebrow') || $block->get('title') || $block->get('subtitle'))
<header class="{{ $alignClass }} max-w-3xl mb-10">
@if ($block->get('eyebrow'))
<p class="ec-eyebrow text-sm font-semibold uppercase tracking-widest mb-3">{{ $block->get('eyebrow') }}</p>
@endif
@if ($block->get('title'))
<h2 class="ec-heading text-3xl sm:text-4xl font-bold">{{ $block->get('title') }}</h2>
@endif
@if ($block->get('subtitle'))
<p class="ec-muted mt-4 text-lg leading-relaxed">{{ $block->get('subtitle') }}</p>
@endif
</header>
@endif
{!! $renderer->renderChildren($block, 'default', $ctx) !!}
</div>
@php
$links = $block->get('use_settings', true)
? collect(($settings->social_links ?? []))->map(fn ($url, $platform) => ['platform' => $platform, 'url' => $url, 'label' => $platform])->filter(fn ($l) => filled($l['url']))->values()
: collect($block->get('links') ?: []);
$size = ['sm' => 'w-9 h-9', 'md' => 'w-11 h-11', 'lg' => 'w-14 h-14'][$block->get('size', 'md')] ?? 'w-11 h-11';
$icon = ['sm' => 'w-4 h-4', 'md' => 'w-5 h-5', 'lg' => 'w-6 h-6'][$block->get('size', 'md')] ?? 'w-5 h-5';
@endphp
<div class="max-w-4xl mx-auto px-4 text-center">
@if ($block->get('title'))
<h2 class="ec-heading text-2xl font-bold mb-6">{{ $block->get('title') }}</h2>
@endif
<div class="flex flex-wrap items-center justify-center gap-3">
@foreach ($links as $link)
@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') }}"
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>
@endif
</a>
@endforeach
</div>
</div>
@php
$h = ['xs' => 'h-4', 'sm' => 'h-8', 'md' => 'h-16', 'lg' => 'h-24', 'xl' => 'h-40'][$block->get('height', 'md')] ?? 'h-16';
$hide = $block->get('hide_on_mobile') ? 'hidden md:block' : '';
@endphp
<div class="{{ $h }} {{ $hide }}" aria-hidden="true"></div>
@php
$animate = $block->get('animate_count', true);
$wrapper = match ($variant) {
'cards' => 'ec-surface rounded-2xl p-8 shadow-sm',
'bordered' => 'px-6 border-e last:border-e-0',
default => 'px-4',
};
@endphp
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@if ($block->get('title'))
<h2 class="ec-heading text-3xl font-bold text-center mb-10">{{ $block->get('title') }}</h2>
@endif
<div class="grid grid-cols-2 lg:grid-cols-4 gap-6 text-center">
@foreach ($items as $i => $item)
<div class="ec-stagger-item {{ $wrapper }} flex flex-col gap-2 items-center">
@if (data_get($item, 'icon'))
<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>
</div>
<p class="ec-muted text-sm sm:text-base">{{ $block->get("items.{$i}.label") ?: data_get($item, 'label') }}</p>
</div>
@endforeach
</div>
</div>
@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')] ?? '';
$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' : '' }}">
<img src="{{ $block->get('image') }}" alt="{{ $block->get('title') }}"
loading="lazy" class="w-full {{ $ratio }} object-cover rounded-2xl">
@if ($block->get('image_caption'))
<figcaption class="ec-muted mt-3 text-sm text-center">{{ $block->get('image_caption') }}</figcaption>
@endif
</figure>
</div>
<div class="{{ $imageFirst ? 'md:order-2' : 'md:order-1' }} flex flex-col gap-5">
@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>
@if ($block->get('body'))
<div class="ec-prose ec-muted leading-relaxed">{!! clean_html($block->get('body')) !!}</div>
@endif
@if ($bullets->isNotEmpty())
<ul class="flex flex-col gap-3">
@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>
</li>
@endforeach
</ul>
@endif
@if ($buttons->isNotEmpty())
<div class="flex flex-wrap gap-3 mt-2">
@foreach ($buttons as $i => $btn)
@php $btnHref = safe_url(data_get($btn, 'url')); @endphp
@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') }}
</a>
@endforeach
</div>
@endif
</div>
</div>
</div>
@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';
$url = $block->get('url');
$embed = website_video_embed_url($url, [
'autoplay' => $block->get('autoplay') ? 1 : 0,
'mute' => $block->get('muted', true) ? 1 : 0,
'loop' => $block->get('loop') ? 1 : 0,
]);
@endphp
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="{{ $variant === 'split' ? 'grid md:grid-cols-2 gap-10 items-center' : '' }}">
<div class="{{ $variant === 'split' ? '' : 'text-center max-w-3xl mx-auto' }} flex flex-col gap-3 {{ $variant === 'split' ? '' : 'mb-8' }}">
@if ($block->get('eyebrow'))
<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>
@endif
@if ($block->get('quote') && $variant === 'with_quote')
<blockquote class="ec-muted text-lg italic leading-relaxed mt-2">
{{ $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>
<div class="{{ $ratio }} w-full overflow-hidden rounded-2xl bg-black">
@if ($embed)
<iframe src="{{ $embed }}" title="{{ $block->get('title') ?: __('فيديو') }}"
class="w-full h-full" loading="lazy" allowfullscreen
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
referrerpolicy="strict-origin-when-cross-origin"></iframe>
@elseif ($url)
<video class="w-full h-full object-cover" controls
@if ($block->get('autoplay')) autoplay @endif
@if ($block->get('muted', true)) muted @endif
@if ($block->get('loop')) loop @endif
@if ($block->get('poster')) poster="{{ $block->get('poster') }}" @endif
playsinline>
<source src="{{ $url }}">
</video>
@endif
</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>
@endif
</div>
{{--
Renders one BlockField as an input bound to $path.
Every block type's editor is generated from this file, so adding a block type
requires no new form markup. Adding a new FieldType requires one case here.
@param \App\Domain\Website\Blocks\BlockField $field
@param string $path wire:model path, e.g. "form.title" or "form.items.0.title"
--}}
@php
use App\Domain\Website\Blocks\FieldType;
$id = 'f-' . \Illuminate\Support\Str::slug(str_replace('.', '-', $path));
$label = $field->label;
@endphp
<div class="flex flex-col gap-1.5"
@if ($field->showIf)
x-show="$wire.form?.{{ $field->showIf }} == {{ json_encode($field->showIfValue) }}" x-cloak
@endif>
@if ($field->type !== FieldType::Toggle)
<label for="{{ $id }}" class="text-sm font-medium">
{{ $label }}@if ($field->required) <span class="text-red-500" aria-hidden="true">*</span>@endif
</label>
@endif
@switch ($field->type)
@case (FieldType::Repeater)
<div class="rounded-lg border divide-y bg-gray-50/60">
@foreach (($this->form[$field->key] ?? []) as $i => $row)
<div class="p-3 space-y-3" wire:key="rep-{{ $field->key }}-{{ $i }}">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold text-gray-500">#{{ $i + 1 }}</span>
<div class="flex items-center gap-1">
<button type="button" wire:click="moveRepeaterRow('{{ $field->key }}', {{ $i }}, -1)"
class="p-1 text-gray-400 hover:text-gray-700" aria-label="{{ __('لأعلى') }}">
<x-ui.icon name="chevron-up" class="w-4 h-4" />
</button>
<button type="button" wire:click="moveRepeaterRow('{{ $field->key }}', {{ $i }}, 1)"
class="p-1 text-gray-400 hover:text-gray-700" aria-label="{{ __('لأسفل') }}">
<x-ui.icon name="chevron-down" class="w-4 h-4" />
</button>
<button type="button" wire:click="removeRepeaterRow('{{ $field->key }}', {{ $i }})"
class="p-1 text-red-500 hover:text-red-700" aria-label="{{ __('حذف') }}">
<x-ui.icon name="trash" class="w-4 h-4" />
</button>
</div>
</div>
@foreach ($field->fields as $sub)
@include('website.builder.field', [
'field' => $sub,
'path' => "{$path}.{$i}.{$sub->key}",
])
@endforeach
</div>
@endforeach
<div class="p-3">
<button type="button" wire:click="addRepeaterRow('{{ $field->key }}')"
@if ($field->max) @disabled(count($this->form[$field->key] ?? []) >= $field->max) @endif
class="inline-flex items-center gap-1.5 text-sm text-primary-600 font-medium disabled:opacity-40">
<x-ui.icon name="plus" class="w-4 h-4" /> {{ __('إضافة') }}
</button>
@if ($field->max)
<span class="text-xs text-gray-400 ms-2">{{ count($this->form[$field->key] ?? []) }}/{{ $field->max }}</span>
@endif
</div>
</div>
@break
@case (FieldType::Textarea)
@case (FieldType::RichText)
@if ($field->translatable)
<textarea id="{{ $id }}" wire:model="{{ $path }}.ar" rows="3" dir="rtl"
placeholder="{{ __('عربي') }}" class="w-full rounded-lg border px-3 py-2 text-sm"></textarea>
<textarea wire:model="{{ $path }}.en" rows="3" dir="ltr"
placeholder="English" class="w-full rounded-lg border px-3 py-2 text-sm"></textarea>
@else
<textarea id="{{ $id }}" wire:model="{{ $path }}" rows="3" class="w-full rounded-lg border px-3 py-2 text-sm"></textarea>
@endif
@break
@case (FieldType::Code)
<textarea id="{{ $id }}" wire:model="{{ $path }}" rows="8" dir="ltr" spellcheck="false"
class="w-full rounded-lg border px-3 py-2 font-mono text-xs"></textarea>
@break
@case (FieldType::Toggle)
<label for="{{ $id }}" class="inline-flex items-center gap-2 cursor-pointer">
<input id="{{ $id }}" type="checkbox" wire:model="{{ $path }}" class="rounded">
<span class="text-sm font-medium">{{ $label }}</span>
</label>
@break
@case (FieldType::Number)
<input id="{{ $id }}" type="number" wire:model="{{ $path }}" dir="ltr"
@if ($field->min !== null) min="{{ $field->min }}" @endif
@if ($field->max !== null) max="{{ $field->max }}" @endif
class="w-full rounded-lg border px-3 py-2 text-sm">
@break
@case (FieldType::Color)
<div class="flex items-center gap-2">
<input id="{{ $id }}" type="color" wire:model="{{ $path }}" class="h-9 w-12 rounded border p-0.5">
<input type="text" wire:model="{{ $path }}" dir="ltr" placeholder="#000000"
class="flex-1 rounded-lg border px-3 py-2 font-mono text-xs">
</div>
@break
@case (FieldType::Select)
@case (FieldType::Radio)
@case (FieldType::Alignment)
@case (FieldType::DataSource)
<select id="{{ $id }}" wire:model="{{ $path }}" class="w-full rounded-lg border px-3 py-2 text-sm">
@unless ($field->required)
<option value="">{{ __(' بدون ') }}</option>
@endunless
@foreach ($field->options as $value => $optionLabel)
<option value="{{ $value }}">{{ $optionLabel }}</option>
@endforeach
</select>
@break
@case (FieldType::Image)
<x-website.media-input :path="$path" :id="$id" />
@break
@case (FieldType::Gallery)
<x-website.media-input :path="$path" :id="$id" multiple />
@break
@case (FieldType::Video)
<input id="{{ $id }}" type="url" wire:model="{{ $path }}" dir="ltr"
placeholder="https://youtube.com/watch?v=..." class="w-full rounded-lg border px-3 py-2 text-sm">
@break
@case (FieldType::Link)
<input id="{{ $id }}" type="text" wire:model="{{ $path }}" dir="ltr"
placeholder="/about-us {{ __('أو') }} https://..." class="w-full rounded-lg border px-3 py-2 text-sm">
@break
@case (FieldType::Icon)
<input id="{{ $id }}" type="text" wire:model="{{ $path }}" dir="ltr" list="ec-icon-names"
placeholder="check" class="w-full rounded-lg border px-3 py-2 text-sm font-mono">
@break
@case (FieldType::Date)
<input id="{{ $id }}" type="date" wire:model="{{ $path }}" dir="ltr" class="w-full rounded-lg border px-3 py-2 text-sm">
@break
@case (FieldType::Map)
<input id="{{ $id }}" type="text" wire:model="{{ $path }}" dir="ltr"
placeholder="30.0444, 31.2357" class="w-full rounded-lg border px-3 py-2 text-sm font-mono">
@break
@default
@if ($field->translatable)
<input id="{{ $id }}" type="text" wire:model="{{ $path }}.ar" dir="rtl"
placeholder="{{ $field->placeholder ?: __('عربي') }}" class="w-full rounded-lg border px-3 py-2 text-sm">
<input type="text" wire:model="{{ $path }}.en" dir="ltr"
placeholder="English" class="w-full rounded-lg border px-3 py-2 text-sm">
@else
<input id="{{ $id }}" type="text" wire:model="{{ $path }}"
placeholder="{{ $field->placeholder }}" class="w-full rounded-lg border px-3 py-2 text-sm">
@endif
@endswitch
@if ($field->help)
<p class="text-xs text-gray-500">{{ $field->help }}</p>
@endif
@error(str_replace('form.', 'form.', $path)) <p class="text-xs text-red-600">{{ $message }}</p> @enderror
@error(str_replace('form.', 'form.', $path) . '.ar') <p class="text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<li wire:key="mi-{{ $item->id }}">
<div class="flex items-center gap-2 rounded-lg px-3 py-2 hover:bg-gray-50" style="margin-inline-start: {{ $depth * 20 }}px">
@if ($item->icon)
<x-website.icon :name="$item->icon" class="w-4 h-4 text-gray-400 shrink-0" />
@endif
<span class="flex-1 min-w-0 truncate {{ $item->is_visible ? '' : 'line-through opacity-50' }}">
{{ $item->label ?: $item->label_en }}
</span>
<span class="text-xs text-gray-400 shrink-0">
@if ($item->isDropdownParent()) {{ __('قائمة منسدلة') }}
@elseif ($item->href()) <span dir="ltr">{{ \Illuminate\Support\Str::limit($item->href(), 30) }}</span>
@else <span class="text-amber-600">{{ __('بدون وجهة') }}</span>
@endif
</span>
@can('settings.manage')
<div class="flex items-center shrink-0">
<button type="button" wire:click="move({{ $item->id }}, -1)" class="p-1 text-gray-300 hover:text-gray-700" aria-label="{{ __('لأعلى') }}"><x-ui.icon name="chevron-up" class="w-4 h-4" /></button>
<button type="button" wire:click="move({{ $item->id }}, 1)" class="p-1 text-gray-300 hover:text-gray-700" aria-label="{{ __('لأسفل') }}"><x-ui.icon name="chevron-down" class="w-4 h-4" /></button>
<button type="button" wire:click="toggleVisible({{ $item->id }})" class="p-1 text-gray-300 hover:text-gray-700" aria-label="{{ __('إظهار/إخفاء') }}"><x-ui.icon name="eye" class="w-4 h-4" /></button>
<button type="button" wire:click="addItem({{ $item->id }})" class="px-2 text-xs text-primary-600">{{ __('+ فرعي') }}</button>
<button type="button" wire:click="edit({{ $item->id }})" class="px-2 text-xs text-gray-600">{{ __('تعديل') }}</button>
<button type="button" wire:click="delete({{ $item->id }})" wire:confirm="{{ __('حذف هذا العنصر؟') }}" class="px-2 text-xs text-red-600">{{ __('حذف') }}</button>
</div>
@endcan
</div>
@foreach ($item->children as $child)
@include('website.builder.menu-row', ['item' => $child, 'depth' => $depth + 1])
@endforeach
</li>
{{-- Entrance animation, delay and child stagger for this block. --}}
<div class="space-y-4">
<div class="flex flex-col gap-1.5">
<label for="mo-anim" class="text-sm font-medium">{{ __('حركة الظهور') }}</label>
<select id="mo-anim" wire:model="styleForm.animation" class="w-full rounded-lg border px-3 py-2 text-sm">
@foreach ([
'none' => 'بدون', 'fade-up' => 'ظهور لأعلى', 'fade-down' => 'ظهور لأسفل',
'fade-left' => 'ظهور من اليسار', 'fade-right' => 'ظهور من اليمين',
'slide-up' => 'انزلاق لأعلى', 'slide-down' => 'انزلاق لأسفل',
'slide-start' => 'انزلاق من البداية', 'slide-end' => 'انزلاق من النهاية',
'zoom-in' => 'تكبير', 'scale-in' => 'تصغير للداخل', 'scale-out' => 'تكبير للخارج',
'rotate-in' => 'دوران', 'flip' => 'قلب', 'flip-y' => 'قلب أفقي',
'blur-in' => 'وضوح تدريجي', 'reveal-up' => 'كشف لأعلى', 'bounce' => 'ارتداد',
] as $v => $l)
<option value="{{ $v }}">{{ __($l) }}</option>
@endforeach
</select>
</div>
<div class="flex flex-col gap-1.5">
<label for="mo-delay" class="text-sm font-medium">{{ __('تأخير البدء') }}</label>
<select id="mo-delay" wire:model="styleForm.animation_delay" class="w-full rounded-lg border px-3 py-2 text-sm">
@foreach ([0 => 'بدون', 100 => '0.1 ثانية', 200 => '0.2 ثانية', 300 => '0.3 ثانية', 400 => '0.4 ثانية', 600 => '0.6 ثانية', 800 => '0.8 ثانية'] as $v => $l)
<option value="{{ $v }}">{{ __($l) }}</option>
@endforeach
</select>
</div>
<div class="flex flex-col gap-1.5">
<label for="mo-stagger" class="text-sm font-medium">{{ __('تتابع ظهور العناصر') }}</label>
<select id="mo-stagger" wire:model="styleForm.animation_stagger" class="w-full rounded-lg border px-3 py-2 text-sm">
@foreach ([0 => 'بدون تتابع', 60 => 'سريع', 90 => 'متوسط', 140 => 'بطيء'] 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>
</div>
{{-- Presentation settings shared by every block, stored in website_blocks.style. --}}
<div class="space-y-4">
<div class="flex flex-col gap-1.5">
<label for="st-bg" class="text-sm font-medium">{{ __('نوع الخلفية') }}</label>
<select id="st-bg" wire:model.live="styleForm.bg_type" class="w-full rounded-lg border px-3 py-2 text-sm">
<option value="none">{{ __('بدون') }}</option>
<option value="solid">{{ __('لون') }}</option>
<option value="gradient">{{ __('تدرج') }}</option>
<option value="image">{{ __('صورة') }}</option>
<option value="video">{{ __('فيديو') }}</option>
</select>
</div>
@if (($styleForm['bg_type'] ?? 'none') === 'solid')
<div class="flex flex-col gap-1.5">
<label for="st-bgc" class="text-sm font-medium">{{ __('لون الخلفية') }}</label>
<div class="flex gap-2">
<input id="st-bgc" type="color" wire:model="styleForm.bg_color" class="h-9 w-12 rounded border p-0.5">
<input type="text" wire:model="styleForm.bg_color" dir="ltr" class="flex-1 rounded-lg border px-3 py-2 font-mono text-xs">
</div>
</div>
@endif
@if (($styleForm['bg_type'] ?? '') === 'gradient')
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label for="st-gf" class="text-sm font-medium">{{ __('من') }}</label>
<input id="st-gf" type="color" wire:model="styleForm.bg_gradient_from" class="h-9 w-full rounded border p-0.5">
</div>
<div class="flex flex-col gap-1.5">
<label for="st-gt" class="text-sm font-medium">{{ __('إلى') }}</label>
<input id="st-gt" type="color" wire:model="styleForm.bg_gradient_to" class="h-9 w-full rounded border p-0.5">
</div>
<div class="col-span-2 flex flex-col gap-1.5">
<label for="st-ga" class="text-sm font-medium">{{ __('الزاوية') }}</label>
<input id="st-ga" type="range" min="0" max="360" step="15" wire:model="styleForm.bg_gradient_angle" class="w-full">
</div>
</div>
@endif
@if (in_array($styleForm['bg_type'] ?? '', ['image', 'video'], true))
<x-website.media-input path="styleForm.bg_image" id="st-bgimg" />
@if (($styleForm['bg_type'] ?? '') === 'video')
<div class="flex flex-col gap-1.5">
<label for="st-bgv" class="text-sm font-medium">{{ __('رابط الفيديو') }}</label>
<input id="st-bgv" type="url" wire:model="styleForm.bg_video" dir="ltr" class="w-full rounded-lg border px-3 py-2 text-sm">
</div>
@endif
<div class="flex flex-col gap-1.5">
<label for="st-ov" class="text-sm font-medium">{{ __('تعتيم الطبقة') }} ({{ $styleForm['bg_overlay_opacity'] ?? 50 }}%)</label>
<input id="st-ov" type="range" min="0" max="100" step="5" wire:model.live="styleForm.bg_overlay_opacity" class="w-full">
</div>
<label class="inline-flex items-center gap-2">
<input type="checkbox" wire:model="styleForm.bg_fixed" class="rounded">
<span class="text-sm">{{ __('تأثير العمق عند التمرير') }}</span>
</label>
@endif
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label for="st-pad" class="text-sm font-medium">{{ __('المسافة الرأسية') }}</label>
<select id="st-pad" wire:model="styleForm.padding_y" class="w-full rounded-lg border px-3 py-2 text-sm">
@foreach (['none' => 'بدون', 'xs' => 'ضيق جدًا', 'sm' => 'ضيق', 'md' => 'متوسط', 'lg' => 'واسع', 'xl' => 'واسع جدًا', '2xl' => 'ضخم'] as $v => $l)
<option value="{{ $v }}">{{ __($l) }}</option>
@endforeach
</select>
</div>
<div class="flex flex-col gap-1.5">
<label for="st-txt" class="text-sm font-medium">{{ __('لون النص') }}</label>
<input id="st-txt" type="color" wire:model="styleForm.text_color" class="h-9 w-full rounded border p-0.5">
</div>
</div>
<div class="flex flex-col gap-1.5">
<label for="st-anchor" class="text-sm font-medium">{{ __('معرّف الرابط') }}</label>
<input id="st-anchor" type="text" wire:model="styleForm.anchor" dir="ltr" placeholder="about"
class="w-full rounded-lg border px-3 py-2 font-mono text-xs">
<p class="text-xs text-gray-500">{{ __('يُستخدم للانتقال المباشر لهذا القسم') }}</p>
</div>
<div class="flex flex-wrap gap-5 pt-1">
<label class="inline-flex items-center gap-2">
<input type="checkbox" wire:model="styleForm.hide_on_mobile" class="rounded">
<span class="text-sm">{{ __('إخفاء على الجوال') }}</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" wire:model="styleForm.hide_on_desktop" class="rounded">
<span class="text-sm">{{ __('إخفاء على الشاشات الكبيرة') }}</span>
</label>
</div>
<div class="flex flex-col gap-1.5">
<label for="st-cls" class="text-sm font-medium">{{ __('أصناف CSS إضافية') }}</label>
<input id="st-cls" type="text" wire:model="styleForm.custom_classes" dir="ltr"
class="w-full rounded-lg border px-3 py-2 font-mono text-xs">
</div>
</div>
{{-- One row in the block tree; recurses into container slots. --}}
@php
$nodeDef = $node->definition();
$isSelected = $selectedId === $node->id;
@endphp
<li wire:key="node-{{ $node->id }}">
<div class="flex items-center gap-1 rounded-lg px-2 py-1.5 {{ $isSelected ? 'bg-primary-50 ring-1 ring-primary-400' : 'hover:bg-gray-50' }}"
style="margin-inline-start: {{ $depth * 12 }}px">
<button type="button" wire:click="select({{ $node->id }})"
class="flex items-center gap-2 flex-1 min-w-0 text-start">
<x-website.icon :name="$nodeDef?->icon() ?? 'x-mark'" class="w-4 h-4 shrink-0 text-gray-500" />
<span class="truncate text-sm {{ $node->is_enabled ? '' : 'line-through opacity-50' }}">
{{ $nodeDef?->label() ?? $node->type }}
</span>
</button>
<div class="flex items-center shrink-0">
<button type="button" wire:click="moveBlock({{ $node->id }}, -1)" class="p-1 text-gray-300 hover:text-gray-700" aria-label="{{ __('لأعلى') }}">
<x-ui.icon name="chevron-up" class="w-3.5 h-3.5" />
</button>
<button type="button" wire:click="moveBlock({{ $node->id }}, 1)" class="p-1 text-gray-300 hover:text-gray-700" aria-label="{{ __('لأسفل') }}">
<x-ui.icon name="chevron-down" class="w-3.5 h-3.5" />
</button>
<button type="button" wire:click="toggleBlock({{ $node->id }})" class="p-1 text-gray-300 hover:text-gray-700"
aria-label="{{ $node->is_enabled ? __('إخفاء') : __('إظهار') }}">
<x-ui.icon name="eye" class="w-3.5 h-3.5" />
</button>
</div>
</div>
@if ($nodeDef?->allowsChildren())
@foreach ($nodeDef->slots() as $slotName)
@php $slotChildren = $node->children->where('slot', $slotName); @endphp
@if (count($nodeDef->slots()) > 1 || $slotChildren->isNotEmpty())
<div style="margin-inline-start: {{ ($depth + 1) * 12 }}px">
@if (count($nodeDef->slots()) > 1)
<p class="text-[10px] uppercase tracking-wider text-gray-400 px-2 pt-1">{{ $slotName }}</p>
@endif
<ul class="space-y-1">
@foreach ($slotChildren as $child)
@include('website.builder.tree-node', ['node' => $child, 'depth' => $depth + 1])
@endforeach
</ul>
<button type="button" wire:click="openPicker({{ $node->id }}, '{{ $slotName }}')"
class="mx-2 my-1 text-xs text-primary-600 hover:underline">+ {{ __('عنصر هنا') }}</button>
</div>
@endif
@endforeach
@endif
</li>
@extends('website.layout')
@section('content')
@if ($preview ?? false)
<div class="sticky top-0 z-40 bg-amber-500 text-black text-center text-sm py-2 px-4 font-medium">
{{ __('معاينة — هذه الصفحة غير منشورة للزوار') }}
</div>
@endif
{!! app(\App\Domain\Website\Services\BlockRenderer::class)->renderPage($page, [
'academy' => $academy,
'settings' => $settings,
'page' => $page,
]) !!}
@endsection
{{--
Renders an authored menu tree.
@param string $menuKey which menu to render (primary, footer, ...)
@param string $style 'horizontal' | 'stacked'
Items whose target cannot be resolved are skipped rather than rendered as a
dead link the project forbids href="#" placeholders.
--}}
@php
$menuItems = app(\App\Domain\Website\Services\WebsiteMenuService::class)->tree($menuKey ?? 'primary');
$style = $style ?? 'horizontal';
@endphp
@if ($menuItems->isNotEmpty())
<ul class="flex {{ $style === 'stacked' ? 'flex-col gap-2' : 'items-center gap-1 lg:gap-2' }}">
@foreach ($menuItems 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="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 font-medium transition hover:opacity-70">
@if ($item->icon)<x-website.icon :name="$item->icon" class="w-4 h-4" />@endif
{{ $item->localizedLabel() }}
<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="absolute z-40 mt-1 min-w-52 rounded-xl border bg-white p-2 shadow-lg
{{ $style === 'stacked' ? 'static border-0 shadow-none p-0 ms-4 mt-2' : 'start-0' }}">
@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 hover:bg-gray-50">
@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="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' : 'hover:opacity-70' }}">
@if ($item->icon)<x-website.icon :name="$item->icon" class="w-4 h-4" />@endif
{{ $item->localizedLabel() }}
</a>
@endif
</li>
@endforeach
</ul>
@endif
...@@ -89,7 +89,7 @@ ...@@ -89,7 +89,7 @@
}); });
// Public Academy Website — serves as home page // Public Academy Website — serves as home page
Route::get('/', [PublicWebsiteController::class, 'home'])->name('home'); Route::get('/', [\App\Http\Controllers\WebsitePageController::class, 'homeOrLegacy'])->name('home');
Route::middleware(\App\Http\Middleware\ResolveAcademyFromSlug::class) Route::middleware(\App\Http\Middleware\ResolveAcademyFromSlug::class)
->prefix('site/{slug}') ->prefix('site/{slug}')
->name('website.') ->name('website.')
...@@ -584,8 +584,12 @@ ...@@ -584,8 +584,12 @@
// ─── Website CMS ───────────────────────────────────────────── // ─── Website CMS ─────────────────────────────────────────────
Route::prefix('website')->name('website.manage.')->middleware('permission:settings.manage')->group(function () { Route::prefix('website')->name('website.manage.')->middleware('permission:settings.manage')->group(function () {
Route::get('/', \App\Livewire\Website\SectionManager::class)->name('sections'); Route::get('/', \App\Livewire\Website\SectionManager::class)->name('sections');
Route::get('/pages', \App\Livewire\Website\PageManager::class)->name('pages');
Route::get('/pages/{page}/builder', \App\Livewire\Website\PageBuilder::class)->name('builder');
Route::get('/menus', \App\Livewire\Website\MenuManager::class)->name('menus');
Route::get('/theme', \App\Livewire\Website\ThemeEditor::class)->name('theme'); Route::get('/theme', \App\Livewire\Website\ThemeEditor::class)->name('theme');
Route::get('/preview', [PublicWebsiteController::class, 'preview'])->name('preview'); Route::get('/preview', [PublicWebsiteController::class, 'preview'])->name('preview');
Route::get('/pages/{page}/preview', [\App\Http\Controllers\WebsitePageController::class, 'preview'])->name('pages.preview');
Route::get('/testimonials', \App\Livewire\Website\TestimonialManager::class)->name('testimonials'); Route::get('/testimonials', \App\Livewire\Website\TestimonialManager::class)->name('testimonials');
Route::get('/faqs', \App\Livewire\Website\FaqManager::class)->name('faqs'); Route::get('/faqs', \App\Livewire\Website\FaqManager::class)->name('faqs');
Route::get('/news', \App\Livewire\Website\NewsManager::class)->name('news'); Route::get('/news', \App\Livewire\Website\NewsManager::class)->name('news');
...@@ -609,3 +613,14 @@ ...@@ -609,3 +613,14 @@
Route::get('/programs', \App\Livewire\Parent\ParentPrograms::class)->name('programs'); Route::get('/programs', \App\Livewire\Parent\ParentPrograms::class)->name('programs');
}); });
}); });
/*
|--------------------------------------------------------------------------
| Website builder pages (must stay last)
|--------------------------------------------------------------------------
| Registered after every other web route so an editor-created slug can never
| shadow a real application route. WebsitePageService also rejects reserved
| slugs at creation time, so this is defence in depth rather than the only guard.
*/
Route::fallback([\App\Http\Controllers\WebsitePageController::class, 'fallback'])
->name('website.page.show');
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