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/",
......
This diff is collapsed.
<?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>
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
<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
This diff is collapsed.
@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>
This diff is collapsed.
This diff is collapsed.
@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>
This diff is collapsed.
This diff is collapsed.
<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>
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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