From 4e4cb03a235795a1c0a58778fec2100b18850d97 Mon Sep 17 00:00:00 2001 From: Dev Agent Bot Date: Fri, 2 Oct 2026 03:17:35 +0000 Subject: [PATCH 1/5] feat(NB-3148): wip - email block composer, registry and renderer - EmailBlockDefinition contract, AbstractEmailBlock, seven block types - EmailBlockRegistry (config + runtime registration) and EmailLayoutRenderer - layout/layout_preview views, block row views, unsubscribe row - EmailBlock model and block library resource - block mode toggle and Builder on the template form; package trait renders layouts - registry and renderer tests Co-Authored-By: Claude Opus 5.5 --- config/filament-email-templates.php | 33 +++ ...d_layout_to_email_templates_table.php.stub | 30 ++ .../create_email_blocks_table.php.stub | 27 ++ resources/views/email/blocks/button.blade.php | 4 + .../views/email/blocks/divider.blade.php | 13 + resources/views/email/blocks/hero.blade.php | 14 + resources/views/email/blocks/image.blade.php | 9 + .../views/email/blocks/rich_text.blade.php | 4 + .../views/email/blocks/two_column.blade.php | 30 ++ resources/views/email/layout.blade.php | 15 + .../views/email/layout_preview.blade.php | 24 ++ .../views/email/parts/_block_button.blade.php | 34 +++ .../views/email/parts/_block_close.blade.php | 10 + .../views/email/parts/_block_open.blade.php | 21 ++ .../parts/_support_block_standalone.blade.php | 2 + .../views/email/parts/_unsubscribe.blade.php | 11 + resources/views/forms/block-preview.blade.php | 7 + src/Blocks/AbstractEmailBlock.php | 155 +++++++++++ src/Blocks/BlockRenderContext.php | 26 ++ src/Blocks/Contracts/EmailBlockDefinition.php | 43 +++ src/Blocks/EmailBlockRegistry.php | 98 +++++++ src/Blocks/Filament/EmailBuilderBlock.php | 24 ++ src/Blocks/Types/ButtonBlock.php | 58 ++++ src/Blocks/Types/DividerBlock.php | 97 +++++++ src/Blocks/Types/HeroBlock.php | 72 +++++ src/Blocks/Types/ImageBlock.php | 59 ++++ src/Blocks/Types/RichTextBlock.php | 45 +++ src/Blocks/Types/SavedBlock.php | 81 ++++++ src/Blocks/Types/TwoColumnBlock.php | 66 +++++ src/EmailTemplatesPlugin.php | 2 + src/EmailTemplatesServiceProvider.php | 9 +- src/Enums/BlockRenderMode.php | 14 + src/Layout/EmailLayoutRenderer.php | 68 +++++ src/Models/EmailBlock.php | 63 +++++ src/Models/EmailTemplate.php | 34 ++- src/Resources/EmailBlockResource.php | 148 ++++++++++ .../Pages/CreateEmailBlock.php | 13 + .../Pages/EditEmailBlock.php | 32 +++ .../Pages/ListEmailBlocks.php | 21 ++ src/Resources/EmailTemplateResource.php | 37 ++- .../Pages/CreateEmailTemplate.php | 9 + .../Pages/EditEmailTemplate.php | 10 + src/Resources/Schemas/EmailLayoutBuilder.php | 42 +++ src/Traits/BuildGenericEmail.php | 11 +- tests/AdminPanelProvider.php | 2 + tests/EmailBlockRegistryTest.php | 75 +++++ tests/EmailLayoutRendererTest.php | 257 ++++++++++++++++++ tests/Fixtures/LayoutTestCampaignMail.php | 9 + tests/Fixtures/LayoutTestMail.php | 21 ++ tests/Fixtures/QuoteBlock.php | 36 +++ tests/Fixtures/ReplacementHeroBlock.php | 30 ++ .../migrations/create_email_blocks_table.php | 27 ++ .../create_email_templates_table_layout.php | 30 ++ 53 files changed, 2105 insertions(+), 7 deletions(-) create mode 100644 database/migrations/add_layout_to_email_templates_table.php.stub create mode 100644 database/migrations/create_email_blocks_table.php.stub create mode 100644 resources/views/email/blocks/button.blade.php create mode 100644 resources/views/email/blocks/divider.blade.php create mode 100644 resources/views/email/blocks/hero.blade.php create mode 100644 resources/views/email/blocks/image.blade.php create mode 100644 resources/views/email/blocks/rich_text.blade.php create mode 100644 resources/views/email/blocks/two_column.blade.php create mode 100644 resources/views/email/layout.blade.php create mode 100644 resources/views/email/layout_preview.blade.php create mode 100644 resources/views/email/parts/_block_button.blade.php create mode 100644 resources/views/email/parts/_block_close.blade.php create mode 100644 resources/views/email/parts/_block_open.blade.php create mode 100644 resources/views/email/parts/_support_block_standalone.blade.php create mode 100644 resources/views/email/parts/_unsubscribe.blade.php create mode 100644 resources/views/forms/block-preview.blade.php create mode 100644 src/Blocks/AbstractEmailBlock.php create mode 100644 src/Blocks/BlockRenderContext.php create mode 100644 src/Blocks/Contracts/EmailBlockDefinition.php create mode 100644 src/Blocks/EmailBlockRegistry.php create mode 100644 src/Blocks/Filament/EmailBuilderBlock.php create mode 100644 src/Blocks/Types/ButtonBlock.php create mode 100644 src/Blocks/Types/DividerBlock.php create mode 100644 src/Blocks/Types/HeroBlock.php create mode 100644 src/Blocks/Types/ImageBlock.php create mode 100644 src/Blocks/Types/RichTextBlock.php create mode 100644 src/Blocks/Types/SavedBlock.php create mode 100644 src/Blocks/Types/TwoColumnBlock.php create mode 100644 src/Enums/BlockRenderMode.php create mode 100644 src/Layout/EmailLayoutRenderer.php create mode 100644 src/Models/EmailBlock.php create mode 100644 src/Resources/EmailBlockResource.php create mode 100644 src/Resources/EmailBlockResource/Pages/CreateEmailBlock.php create mode 100644 src/Resources/EmailBlockResource/Pages/EditEmailBlock.php create mode 100644 src/Resources/EmailBlockResource/Pages/ListEmailBlocks.php create mode 100644 src/Resources/Schemas/EmailLayoutBuilder.php create mode 100644 tests/EmailBlockRegistryTest.php create mode 100644 tests/EmailLayoutRendererTest.php create mode 100644 tests/Fixtures/LayoutTestCampaignMail.php create mode 100644 tests/Fixtures/LayoutTestMail.php create mode 100644 tests/Fixtures/QuoteBlock.php create mode 100644 tests/Fixtures/ReplacementHeroBlock.php create mode 100644 tests/migrations/create_email_blocks_table.php create mode 100644 tests/migrations/create_email_templates_table_layout.php diff --git a/config/filament-email-templates.php b/config/filament-email-templates.php index 413ebc7..4258c44 100644 --- a/config/filament-email-templates.php +++ b/config/filament-email-templates.php @@ -9,6 +9,7 @@ */ 'table_name' => 'vb_email_templates', 'theme_table_name' => 'vb_email_templates_themes', + 'blocks_table_name' => 'vb_email_blocks', /** * Flag-icon stylesheet @@ -103,6 +104,14 @@ 'cluster' => false, 'position' => SubNavigationPosition::Top ], + 'blocks' => [ + 'sort' => 15, + 'label' => 'Email Blocks', + 'icon' => 'heroicon-o-squares-2x2', + 'group' => 'Content', + 'cluster' => false, + 'position' => SubNavigationPosition::Top + ], ], //Email templates will be copied to resources/views/vendor/vb-email-templates/email @@ -134,6 +143,30 @@ //Content Width in Pixels 'content_width' => '600', + //Font stack used by the email block views + 'font_family' => "'Lato', Helvetica, Arial, sans-serif", + + /** + * Email blocks (block composer) + * + * block_types: the block classes offered in the "Add block" menu, in order. + * Append your own EmailBlockDefinition classes here. + * block_images: upload directory for block images + * block_images_disk: disk for block images; null uses filament.default_filesystem_disk, + * then 'public'. Must serve public URLs that mail clients can fetch. + */ + 'block_types' => [ + \Visualbuilder\EmailTemplates\Blocks\Types\RichTextBlock::class, + \Visualbuilder\EmailTemplates\Blocks\Types\HeroBlock::class, + \Visualbuilder\EmailTemplates\Blocks\Types\TwoColumnBlock::class, + \Visualbuilder\EmailTemplates\Blocks\Types\ButtonBlock::class, + \Visualbuilder\EmailTemplates\Blocks\Types\ImageBlock::class, + \Visualbuilder\EmailTemplates\Blocks\Types\DividerBlock::class, + \Visualbuilder\EmailTemplates\Blocks\Types\SavedBlock::class, + ], + 'block_images' => 'media/email-templates/blocks', + 'block_images_disk' => null, + //Contact details included in default email templates 'customer-services' => [ 'email' => 'support@yourcompany.com', diff --git a/database/migrations/add_layout_to_email_templates_table.php.stub b/database/migrations/add_layout_to_email_templates_table.php.stub new file mode 100644 index 0000000..a54877b --- /dev/null +++ b/database/migrations/add_layout_to_email_templates_table.php.stub @@ -0,0 +1,30 @@ +json('layout')->nullable()->after('content'); + }); + } + } + + public function down(): void + { + $table = config('filament-email-templates.table_name'); + + if (Schema::hasColumn($table, 'layout')) { + Schema::table($table, function (Blueprint $table) { + $table->dropColumn('layout'); + }); + } + } +}; diff --git a/database/migrations/create_email_blocks_table.php.stub b/database/migrations/create_email_blocks_table.php.stub new file mode 100644 index 0000000..4a58015 --- /dev/null +++ b/database/migrations/create_email_blocks_table.php.stub @@ -0,0 +1,27 @@ +id(); + $table->string('name', 120); + $table->string('description', 255)->nullable(); + $table->json('layout'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + $table->index('is_active'); + }); + } + + public function down(): void + { + Schema::dropIfExists(config('filament-email-templates.blocks_table_name', 'vb_email_blocks')); + } +}; diff --git a/resources/views/email/blocks/button.blade.php b/resources/views/email/blocks/button.blade.php new file mode 100644 index 0000000..322d158 --- /dev/null +++ b/resources/views/email/blocks/button.blade.php @@ -0,0 +1,4 @@ + +@include('vb-email-templates::email.parts._block_open', ['theme' => $theme, 'padding' => '10px 30px 10px 30px']) +@include('vb-email-templates::email.parts._block_button', ['theme' => $theme, 'label' => $block['label'] ?? '', 'url' => $block['url'] ?? '', 'align' => $block['align'] ?? 'center']) +@include('vb-email-templates::email.parts._block_close') diff --git a/resources/views/email/blocks/divider.blade.php b/resources/views/email/blocks/divider.blade.php new file mode 100644 index 0000000..3023c06 --- /dev/null +++ b/resources/views/email/blocks/divider.blade.php @@ -0,0 +1,13 @@ + +@php + $space = ['small' => 16, 'medium' => 32, 'large' => 48][$block['spacing'] ?? 'medium'] ?? 32; + $showLine = (bool) ($block['show_line'] ?? true); + $cellBg = $theme['content_bg_color'] ?? '#ffffff'; +@endphp +@include('vb-email-templates::email.parts._block_open', ['theme' => $theme, 'padding' => $space.'px 30px '.$space.'px 30px']) + + + + +
 
+@include('vb-email-templates::email.parts._block_close') diff --git a/resources/views/email/blocks/hero.blade.php b/resources/views/email/blocks/hero.blade.php new file mode 100644 index 0000000..76af09c --- /dev/null +++ b/resources/views/email/blocks/hero.blade.php @@ -0,0 +1,14 @@ + +@php($imageWidth = (int) config('filament-email-templates.content_width') - 60) +@include('vb-email-templates::email.parts._block_open', ['theme' => $theme, 'padding' => '30px 30px 30px 30px']) +@if(filled($block['image'] ?? null)) + {{ $block['image_alt'] ?? '' }} +@endif +

{{ $block['heading'] ?? '' }}

+@if(filled($block['text'] ?? null)) +

{!! nl2br(e($block['text'])) !!}

+@endif +@if(filled($block['button_label'] ?? null) && filled($block['button_url'] ?? null)) + @include('vb-email-templates::email.parts._block_button', ['theme' => $theme, 'label' => $block['button_label'], 'url' => $block['button_url'], 'align' => 'left']) +@endif +@include('vb-email-templates::email.parts._block_close') diff --git a/resources/views/email/blocks/image.blade.php b/resources/views/email/blocks/image.blade.php new file mode 100644 index 0000000..f7f9948 --- /dev/null +++ b/resources/views/email/blocks/image.blade.php @@ -0,0 +1,9 @@ + +@php($imageWidth = (int) config('filament-email-templates.content_width') - 60) +@if(filled($block['image'] ?? null)) +@include('vb-email-templates::email.parts._block_open', ['theme' => $theme, 'align' => 'center']) +@if(filled($block['link_url'] ?? null))@endif +{{ $block['alt'] ?? '' }} +@if(filled($block['link_url'] ?? null))@endif +@include('vb-email-templates::email.parts._block_close') +@endif diff --git a/resources/views/email/blocks/rich_text.blade.php b/resources/views/email/blocks/rich_text.blade.php new file mode 100644 index 0000000..5db3b49 --- /dev/null +++ b/resources/views/email/blocks/rich_text.blade.php @@ -0,0 +1,4 @@ + +@include('vb-email-templates::email.parts._block_open', ['theme' => $theme]) +{!! $block['content'] ?? '' !!} +@include('vb-email-templates::email.parts._block_close') diff --git a/resources/views/email/blocks/two_column.blade.php b/resources/views/email/blocks/two_column.blade.php new file mode 100644 index 0000000..d342839 --- /dev/null +++ b/resources/views/email/blocks/two_column.blade.php @@ -0,0 +1,30 @@ + +@php + // Each column holds (content_width - 80) / 2 of content; 10px side padding per + // column gives a 20px gutter side by side and keeps the 30px edge when stacked. + $column = intdiv((int) config('filament-email-templates.content_width') - 80, 2); + $cellBg = $theme['content_bg_color'] ?? '#ffffff'; +@endphp +@include('vb-email-templates::email.parts._block_open', ['theme' => $theme, 'padding' => '20px 20px 20px 20px', 'align' => 'center']) +
+ +@foreach(['left', 'right'] as $side) +@if($side === 'right') + +@endif +
+ + + + +
+ @if(filled($block[$side.'_image'] ?? null)) + {{ $block[$side.'_image_alt'] ?? '' }} + @endif + {!! $block[$side.'_content'] ?? '' !!} +
+
+@endforeach + +
+@include('vb-email-templates::email.parts._block_close') diff --git a/resources/views/email/layout.blade.php b/resources/views/email/layout.blade.php new file mode 100644 index 0000000..41843b0 --- /dev/null +++ b/resources/views/email/layout.blade.php @@ -0,0 +1,15 @@ +@include('vb-email-templates::email.parts._head') + +@include('vb-email-templates::email.parts._body') + +@include('vb-email-templates::email.parts._hero_title') + +{!! $data['blocks'] ?? '' !!} + +@include('vb-email-templates::email.parts._support_block_standalone') + +@include('vb-email-templates::email.parts._footer') + +@include('vb-email-templates::email.parts._unsubscribe') + +@include('vb-email-templates::email.parts._closing_tags') diff --git a/resources/views/email/layout_preview.blade.php b/resources/views/email/layout_preview.blade.php new file mode 100644 index 0000000..1bf9251 --- /dev/null +++ b/resources/views/email/layout_preview.blade.php @@ -0,0 +1,24 @@ + +@php + if (isset($data['colours'])) { + $data['theme'] = $data['colours']; + } elseif (isset($this) && isset($this->data['colours'])) { + $data['theme'] = $this->data['colours']; + } +@endphp + +
+ +@include('vb-email-templates::email.parts._body') + +@include('vb-email-templates::email.parts._hero_title') + +{!! $data['blocks'] ?? '' !!} + +@include('vb-email-templates::email.parts._support_block_standalone') + +@include('vb-email-templates::email.parts._footer') + +@include('vb-email-templates::email.parts._unsubscribe') + +
diff --git a/resources/views/email/parts/_block_button.blade.php b/resources/views/email/parts/_block_button.blade.php new file mode 100644 index 0000000..e608c36 --- /dev/null +++ b/resources/views/email/parts/_block_button.blade.php @@ -0,0 +1,34 @@ +{{-- + Bulletproof button: VML roundrect for Outlook desktop, a padded link elsewhere. + Expects $theme, $label, $url; optional $align (left|center|right). +--}} +@php + $buttonBg = $theme['button_bg_color'] ?? '#1e88e5'; + $buttonColor = $theme['button_color'] ?? '#ffffff'; + $buttonAlign = in_array($align ?? 'center', ['left', 'center', 'right'], true) ? ($align ?? 'center') : 'center'; + $buttonWidth = min( + (int) config('filament-email-templates.content_width') - 60, + max(160, mb_strlen((string) $label) * 11 + 60) + ); +@endphp + + + + +
+ + + + + + +
+ {{ $label }} +
+ +
diff --git a/resources/views/email/parts/_block_close.blade.php b/resources/views/email/parts/_block_close.blade.php new file mode 100644 index 0000000..ffa40d9 --- /dev/null +++ b/resources/views/email/parts/_block_close.blade.php @@ -0,0 +1,10 @@ + + + + + + diff --git a/resources/views/email/parts/_block_open.blade.php b/resources/views/email/parts/_block_open.blade.php new file mode 100644 index 0000000..5747aef --- /dev/null +++ b/resources/views/email/parts/_block_open.blade.php @@ -0,0 +1,21 @@ +{{-- + Opens one block row: a full-width band in body_bg_color (as _content.blade.php), + an Outlook ghost table at content_width, and the content cell. + Expects $theme; optional $padding and $align for the content cell. + Close with _block_close. +--}} +@php + $contentWidth = (int) config('filament-email-templates.content_width'); + $bandBg = $theme['body_bg_color'] ?? '#ffffff'; + $cellBg = $theme['content_bg_color'] ?? '#ffffff'; +@endphp + + + + + + '); +}); + +it('adds a host block listed in the block_types config', function () { + config(['filament-email-templates.block_types' => [...EmailBlockRegistry::DEFAULT_TYPES, QuoteBlock::class]]); + + expect(array_keys((new EmailBlockRegistry)->all()))->toContain('quote') + ->and(array_key_last((new EmailBlockRegistry)->all()))->toBe('quote'); +}); + +it('replaces a package block in place when a later class has the same name', function () { + $registry = (new EmailBlockRegistry)->register(ReplacementHeroBlock::class); + + expect(array_keys($registry->all()))->toBe(['rich_text', 'hero', 'two_column', 'button', 'image', 'divider', 'saved_block']) + ->and($registry->get('hero'))->toBeInstanceOf(ReplacementHeroBlock::class); +}); + +it('rejects a class that does not implement the contract', function () { + (new EmailBlockRegistry)->register(stdClass::class)->all(); +})->throws(InvalidArgumentException::class, 'stdClass must implement EmailBlockDefinition'); + +it('returns null for an unknown block name', function () { + expect(app(EmailBlockRegistry::class)->get('nope'))->toBeNull(); +}); diff --git a/tests/EmailLayoutRendererTest.php b/tests/EmailLayoutRendererTest.php new file mode 100644 index 0000000..48bf659 --- /dev/null +++ b/tests/EmailLayoutRendererTest.php @@ -0,0 +1,257 @@ + '#1E88E5', + 'body_bg_color' => '#f4f4f4', + 'content_bg_color' => '#FFFFFB', + 'footer_bg_color' => '#34495E', + 'callout_bg_color' => '#FFC107', + 'button_bg_color' => '#FFEB3B', + 'body_color' => '#333333', + 'callout_color' => '#212121', + 'button_color' => '#2A2A11', + 'anchor_color' => '#1E88E5', + ]; +} + +function layoutRenderer(): EmailLayoutRenderer +{ + return app(EmailLayoutRenderer::class)->withTheme(layoutTheme()); +} + +function layoutModels(string $name = 'Ada Lovelace'): object +{ + return (object) ['user' => User::factory()->make(['name' => $name])]; +} + +it('renders a text block as one row with raw rich text and tokens replaced', function () { + $html = layoutRenderer()->render([ + 'a1' => ['type' => 'rich_text', 'data' => ['content' => '

Hello ##user.name##

']], + ], layoutModels()); + + expect($html)->toContain('') + ->toContain('

Hello Ada Lovelace

') + ->toContain('bgcolor="#FFFFFB"') + ->toContain('background-color: #FFFFFB') + ->toContain('max-width: 600px') + ->toContain('', '', $html)))->toStartWith('')->toEndWith(''); +}); + +it('leaves tokens unreplaced when there are no models (builder preview)', function () { + $html = layoutRenderer()->renderBlock('rich_text', ['content' => '

Hi ##user.name##

'], null); + + expect($html)->toContain('

Hi ##user.name##

'); +}); + +it('renders a hero with escaped plain fields, a public image URL and a button', function () { + Storage::fake('public', ['url' => 'https://cdn.test']); + config(['filament-email-templates.block_images_disk' => 'public']); + + $html = layoutRenderer()->renderBlock('hero', [ + 'image' => 'media/email-templates/blocks/spring.png', + 'image_alt' => 'Spring banner', + 'heading' => 'Spring offer for ##user.name##', + 'text' => "Line one\nLine two", + 'button_label' => 'Book now', + 'button_url' => 'https://example.com/book', + ], layoutModels()); + + expect($html)->toContain('') + ->toContain('Spring <script>alert(1)</script> offer for Ada Lovelace') + ->not->toContain('']) + ->render(); + + expect($html)->not->toContain('') + ->toContain('onerror="alert(2)"'); +}); + +it('accepts only raster images for block image uploads', function () { + $upload = collect((new ImageBlock)->schema())->first(fn ($component) => $component instanceof FileUpload); + + expect($upload->getAcceptedFileTypes())->toBe(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); });
diff --git a/resources/views/email/parts/_support_block_standalone.blade.php b/resources/views/email/parts/_support_block_standalone.blade.php new file mode 100644 index 0000000..b6799bc --- /dev/null +++ b/resources/views/email/parts/_support_block_standalone.blade.php @@ -0,0 +1,2 @@ +{{-- Block mode: a self-contained support row. Override this file when your _support_block is a nested fragment. --}} +@include('vb-email-templates::email.parts._support_block') diff --git a/resources/views/email/parts/_unsubscribe.blade.php b/resources/views/email/parts/_unsubscribe.blade.php new file mode 100644 index 0000000..3706949 --- /dev/null +++ b/resources/views/email/parts/_unsubscribe.blade.php @@ -0,0 +1,11 @@ +@if(! empty($data['unsubscribeUrl'])) +@php($unsubscribeBg = $data['theme']['footer_bg_color'] ?? $data['theme']['body_bg_color'] ?? '#ffffff') + + + + + +
+ You are receiving this because you subscribed. Unsubscribe +
+@endif diff --git a/resources/views/forms/block-preview.blade.php b/resources/views/forms/block-preview.blade.php new file mode 100644 index 0000000..72a9cf9 --- /dev/null +++ b/resources/views/forms/block-preview.blade.php @@ -0,0 +1,7 @@ +@php + $theme = \Visualbuilder\EmailTemplates\Models\EmailTemplateTheme::query()->where('is_default', true)->first()?->colours ?? []; + $html = app(\Visualbuilder\EmailTemplates\Layout\EmailLayoutRenderer::class)->withTheme($theme)->renderBlock($type, $data, null); +@endphp + + {!! $html !!} +
diff --git a/src/Blocks/AbstractEmailBlock.php b/src/Blocks/AbstractEmailBlock.php new file mode 100644 index 0000000..83c4df8 --- /dev/null +++ b/src/Blocks/AbstractEmailBlock.php @@ -0,0 +1,155 @@ + + */ + protected function tokenFields(): array + { + return []; + } + + /** + * Data keys holding a stored upload path, replaced by a public URL. + * + * @return array + */ + protected function imageFields(): array + { + return []; + } + + /** + * Public URL for a stored block image. An image picked but not saved yet + * (a Livewire temporary upload) returns null, so a preview leaves it out + * until the record is saved. + */ + public static function imageUrl(mixed $value): ?string + { + if (is_array($value)) { + $value = Arr::first($value); + } + + if ($value instanceof TemporaryUploadedFile || blank($value) || ! is_string($value)) { + return null; + } + + return Str::isUrl($value) ? $value : Storage::disk(static::imageDisk())->url($value); + } + + /** + * Images must be served from a public URL that mail clients can fetch; + * asset() would point at the (possibly protected) admin domain. + */ + public static function imageDisk(): string + { + return config('filament-email-templates.block_images_disk') + ?: (config('filament.default_filesystem_disk') ?: 'public'); + } + + public function view(BlockRenderMode $mode): ?string + { + return match ($mode) { + BlockRenderMode::Email => 'vb-email-templates::email.blocks.'.$this->name(), + }; + } + + public function summary(array $data): ?string + { + return null; + } + + public function nestable(): bool + { + return true; + } + + public function render(array $data, BlockRenderContext $context): string + { + $view = $this->view($context->mode); + + if ($view === null) { + return ''; + } + + return view($view, [ + 'block' => $this->prepare($data, $context), + 'theme' => $context->theme, + ])->render(); + } + + /** + * @param array $data + * @return array + */ + protected function prepare(array $data, BlockRenderContext $context): array + { + if ($context->models !== null) { + foreach ($this->tokenFields() as $key) { + if (is_string($data[$key] ?? null)) { + $data[$key] = TokenHelper::replace($data[$key], $context->models); + } + } + } + + foreach ($this->imageFields() as $key) { + $data[$key] = static::imageUrl($data[$key] ?? null); + } + + return $data; + } + + /** + * First $limit characters of the text in an HTML fragment, or null when empty. + */ + protected static function textSummary(mixed $html, int $limit = 50): ?string + { + $text = trim(html_entity_decode(strip_tags(is_string($html) ? $html : ''), ENT_QUOTES | ENT_HTML5)); + + return $text === '' ? null : Str::substr($text, 0, $limit); + } + + /** Image upload stored with public visibility on the block image disk. */ + protected static function imageUpload(string $name): FileUpload + { + return FileUpload::make($name) + ->image() + ->disk(static::imageDisk()) + ->visibility('public') + ->directory(config('filament-email-templates.block_images')) + ->maxSize(2048); + } + + /** Rich text editor configured like the template body editor. */ + protected static function richEditor(string $name): TinyEditor + { + return TinyEditor::make($name) + ->profile('email-template') + ->setCustomConfigs(fn () => ['vbtokens_list' => app(TokenRegistry::class)->menu()]); + } +} diff --git a/src/Blocks/BlockRenderContext.php b/src/Blocks/BlockRenderContext.php new file mode 100644 index 0000000..702e0c5 --- /dev/null +++ b/src/Blocks/BlockRenderContext.php @@ -0,0 +1,26 @@ + $theme + */ + public function __construct( + public readonly EmailLayoutRenderer $renderer, + public readonly array $theme, + public readonly object|array|null $models, + public readonly BlockRenderMode $mode = BlockRenderMode::Email, + public readonly int $depth = 0, + ) {} +} diff --git a/src/Blocks/Contracts/EmailBlockDefinition.php b/src/Blocks/Contracts/EmailBlockDefinition.php new file mode 100644 index 0000000..c6a0a6d --- /dev/null +++ b/src/Blocks/Contracts/EmailBlockDefinition.php @@ -0,0 +1,43 @@ + */ + public function schema(): array; + + /** Blade view for the mode, or null when the block has no view of its own in that mode. */ + public function view(BlockRenderMode $mode): ?string; + + /** + * Short text shown after the label in the Builder header (collapsed or not), or null. + * + * @param array $data + */ + public function summary(array $data): ?string; + + /** False when the block may not be used inside a saved block (only SavedBlock). */ + public function nestable(): bool; + + /** @param array $data */ + public function render(array $data, BlockRenderContext $context): string; +} diff --git a/src/Blocks/EmailBlockRegistry.php b/src/Blocks/EmailBlockRegistry.php new file mode 100644 index 0000000..d3f26ea --- /dev/null +++ b/src/Blocks/EmailBlockRegistry.php @@ -0,0 +1,98 @@ +> The order of the "Add block" menu. */ + public const DEFAULT_TYPES = [ + RichTextBlock::class, + HeroBlock::class, + TwoColumnBlock::class, + ButtonBlock::class, + ImageBlock::class, + DividerBlock::class, + SavedBlock::class, + ]; + + /** @var array */ + protected array $registered = []; + + /** @var array|null */ + protected ?array $definitions = null; + + public function register(string $class): static + { + $this->registered[] = $class; + $this->definitions = null; + + return $this; + } + + /** + * @return array keyed by name() + */ + public function all(): array + { + if ($this->definitions !== null) { + return $this->definitions; + } + + $classes = array_merge( + config('filament-email-templates.block_types') ?: self::DEFAULT_TYPES, + $this->registered, + ); + + $definitions = []; + + foreach ($classes as $class) { + $definition = app($class); + + if (! $definition instanceof EmailBlockDefinition) { + throw new InvalidArgumentException("{$class} must implement EmailBlockDefinition"); + } + + // Assigning to an existing key keeps its position. + $definitions[$definition->name()] = $definition; + } + + return $this->definitions = $definitions; + } + + public function get(string $name): ?EmailBlockDefinition + { + return $this->all()[$name] ?? null; + } + + /** + * Block types that may be used inside a saved block. + * + * @return array + */ + public function nestable(): array + { + return array_filter($this->all(), fn (EmailBlockDefinition $definition): bool => $definition->nestable()); + } +} diff --git a/src/Blocks/Filament/EmailBuilderBlock.php b/src/Blocks/Filament/EmailBuilderBlock.php new file mode 100644 index 0000000..8ca258d --- /dev/null +++ b/src/Blocks/Filament/EmailBuilderBlock.php @@ -0,0 +1,24 @@ + $data + */ + public function renderPreview(array $data): View + { + return view('vb-email-templates::forms.block-preview', ['type' => $this->getName(), 'data' => $data]); + } +} diff --git a/src/Blocks/Types/ButtonBlock.php b/src/Blocks/Types/ButtonBlock.php new file mode 100644 index 0000000..3202fb4 --- /dev/null +++ b/src/Blocks/Types/ButtonBlock.php @@ -0,0 +1,58 @@ +label('Button label') + ->required() + ->maxLength(40), + TextInput::make('url') + ->label('Button link') + ->required() + ->maxLength(2048) + ->regex(self::URL_PATTERN), + Select::make('align') + ->label('Alignment') + ->options(['left' => 'Left', 'center' => 'Centre', 'right' => 'Right']) + ->default('center') + ->required(), + ]; + } + + public function summary(array $data): ?string + { + return filled($data['label'] ?? null) ? (string) $data['label'] : null; + } + + protected function tokenFields(): array + { + return ['label', 'url']; + } +} diff --git a/src/Blocks/Types/DividerBlock.php b/src/Blocks/Types/DividerBlock.php new file mode 100644 index 0000000..f002a51 --- /dev/null +++ b/src/Blocks/Types/DividerBlock.php @@ -0,0 +1,97 @@ + 'Small', 'medium' => 'Medium', 'large' => 'Large']; + + public function name(): string + { + return 'divider'; + } + + public function label(): string + { + return 'Divider / spacer'; + } + + public function icon(): Heroicon + { + return Heroicon::OutlinedMinus; + } + + public function schema(): array + { + return [ + Select::make('spacing') + ->label('Spacing') + ->options(self::SPACINGS) + ->default('medium') + ->required(), + Toggle::make('show_line') + ->label('Show a line') + ->default(true), + ]; + } + + public function summary(array $data): ?string + { + return self::SPACINGS[$data['spacing'] ?? ''] ?? null; + } + + protected function prepare(array $data, BlockRenderContext $context): array + { + $data = parent::prepare($data, $context); + $data['line_color'] = self::lineColour($context->theme); + + return $data; + } + + /** + * body_color at 20% over content_bg_color, as a solid hex: email clients + * do not reliably apply opacity to borders. #e5e7eb when either colour is + * missing or not a hex value. + * + * @param array $theme + */ + public static function lineColour(array $theme): string + { + $foreground = self::rgb($theme['body_color'] ?? null); + $background = self::rgb($theme['content_bg_color'] ?? null); + + if ($foreground === null || $background === null) { + return '#e5e7eb'; + } + + $mixed = array_map( + fn (int $fg, int $bg): int => (int) round($fg * 0.2 + $bg * 0.8), + $foreground, + $background, + ); + + return vsprintf('#%02x%02x%02x', $mixed); + } + + /** @return array{0: int, 1: int, 2: int}|null */ + private static function rgb(mixed $hex): ?array + { + if (! is_string($hex) || ! preg_match('/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i', trim($hex), $matches)) { + return null; + } + + $digits = strlen($matches[1]) === 3 + ? implode('', array_map(fn (string $c): string => $c.$c, str_split($matches[1]))) + : $matches[1]; + + return [hexdec(substr($digits, 0, 2)), hexdec(substr($digits, 2, 2)), hexdec(substr($digits, 4, 2))]; + } +} diff --git a/src/Blocks/Types/HeroBlock.php b/src/Blocks/Types/HeroBlock.php new file mode 100644 index 0000000..2f26745 --- /dev/null +++ b/src/Blocks/Types/HeroBlock.php @@ -0,0 +1,72 @@ +label('Image'), + TextInput::make('image_alt') + ->label('Image description (alt text)') + ->maxLength(150) + ->requiredWith('image'), + TextInput::make('heading') + ->label('Heading') + ->required() + ->maxLength(120), + Textarea::make('text') + ->label('Text') + ->maxLength(500) + ->rows(3), + TextInput::make('button_label') + ->label('Button label') + ->maxLength(40) + ->requiredWith('button_url'), + TextInput::make('button_url') + ->label('Button link') + ->maxLength(2048) + ->requiredWith('button_label') + ->regex(self::URL_PATTERN), + ]; + } + + public function summary(array $data): ?string + { + return filled($data['heading'] ?? null) ? (string) $data['heading'] : null; + } + + protected function tokenFields(): array + { + return ['heading', 'text', 'button_label', 'button_url']; + } + + protected function imageFields(): array + { + return ['image']; + } +} diff --git a/src/Blocks/Types/ImageBlock.php b/src/Blocks/Types/ImageBlock.php new file mode 100644 index 0000000..d89b296 --- /dev/null +++ b/src/Blocks/Types/ImageBlock.php @@ -0,0 +1,59 @@ +label('Image') + ->required(), + TextInput::make('alt') + ->label('Image description (alt text)') + ->required() + ->maxLength(150), + TextInput::make('link_url') + ->label('Link') + ->maxLength(2048) + ->regex(self::URL_PATTERN), + ]; + } + + public function summary(array $data): ?string + { + return filled($data['alt'] ?? null) ? (string) $data['alt'] : null; + } + + protected function tokenFields(): array + { + return ['link_url']; + } + + protected function imageFields(): array + { + return ['image']; + } +} diff --git a/src/Blocks/Types/RichTextBlock.php b/src/Blocks/Types/RichTextBlock.php new file mode 100644 index 0000000..307f47e --- /dev/null +++ b/src/Blocks/Types/RichTextBlock.php @@ -0,0 +1,45 @@ +label('Text') + ->required(), + ]; + } + + public function summary(array $data): ?string + { + return static::textSummary($data['content'] ?? null); + } + + protected function tokenFields(): array + { + return ['content']; + } +} diff --git a/src/Blocks/Types/SavedBlock.php b/src/Blocks/Types/SavedBlock.php new file mode 100644 index 0000000..4da1a4a --- /dev/null +++ b/src/Blocks/Types/SavedBlock.php @@ -0,0 +1,81 @@ +label('Saved block') + ->options(fn (): array => EmailBlock::active()->orderBy('name')->pluck('name', 'id')->all()) + ->searchable() + ->required(), + ]; + } + + public function summary(array $data): ?string + { + return EmailBlock::withTrashed()->find($data['email_block_id'] ?? null)?->name; + } + + public function nestable(): bool + { + return false; + } + + public function view(BlockRenderMode $mode): ?string + { + return null; + } + + public function render(array $data, BlockRenderContext $context): string + { + if ($context->depth >= 1) { + Log::warning('email layout: saved block inside a saved block ignored', ['email_block_id' => $data['email_block_id'] ?? null]); + + return ''; + } + + $block = EmailBlock::active()->find($data['email_block_id'] ?? null); + + if ($block === null) { + Log::warning('email layout: saved block missing or inactive', ['email_block_id' => $data['email_block_id'] ?? null]); + + return ''; + } + + return $context->renderer->render($block->layout ?? [], $context->models, $context->depth + 1); + } +} diff --git a/src/Blocks/Types/TwoColumnBlock.php b/src/Blocks/Types/TwoColumnBlock.php new file mode 100644 index 0000000..27ba0d6 --- /dev/null +++ b/src/Blocks/Types/TwoColumnBlock.php @@ -0,0 +1,66 @@ +label('Left image'), + TextInput::make('left_image_alt') + ->label('Left image description (alt text)') + ->maxLength(150) + ->requiredWith('left_image'), + static::richEditor('left_content') + ->label('Left text') + ->required(), + static::imageUpload('right_image') + ->label('Right image'), + TextInput::make('right_image_alt') + ->label('Right image description (alt text)') + ->maxLength(150) + ->requiredWith('right_image'), + static::richEditor('right_content') + ->label('Right text') + ->required(), + ]; + } + + public function summary(array $data): ?string + { + return static::textSummary($data['left_content'] ?? null); + } + + protected function tokenFields(): array + { + return ['left_content', 'right_content']; + } + + protected function imageFields(): array + { + return ['left_image', 'right_image']; + } +} diff --git a/src/EmailTemplatesPlugin.php b/src/EmailTemplatesPlugin.php index 2acfe5b..0e54e5b 100644 --- a/src/EmailTemplatesPlugin.php +++ b/src/EmailTemplatesPlugin.php @@ -8,6 +8,7 @@ use Filament\Panel; use Filament\Support\Concerns\EvaluatesClosures; use Illuminate\Support\Str; +use Visualbuilder\EmailTemplates\Resources\EmailBlockResource; use Visualbuilder\EmailTemplates\Resources\EmailTemplateResource; use Visualbuilder\EmailTemplates\Resources\EmailTemplateThemeResource; @@ -168,6 +169,7 @@ public function register(Panel $panel): void $panel->resources([ EmailTemplateResource::class, EmailTemplateThemeResource::class, + EmailBlockResource::class, ]); } diff --git a/src/EmailTemplatesServiceProvider.php b/src/EmailTemplatesServiceProvider.php index 63463e3..3f13a83 100644 --- a/src/EmailTemplatesServiceProvider.php +++ b/src/EmailTemplatesServiceProvider.php @@ -8,6 +8,7 @@ use Spatie\LaravelPackageTools\PackageServiceProvider; use Visualbuilder\EmailTemplates\Commands\InstallCommand; use Visualbuilder\EmailTemplates\Commands\WrapContentTokensCommand; +use Visualbuilder\EmailTemplates\Blocks\EmailBlockRegistry; use Visualbuilder\EmailTemplates\Contracts\CreateMailableInterface; use Visualbuilder\EmailTemplates\Contracts\FormHelperInterface; @@ -18,7 +19,12 @@ class EmailTemplatesServiceProvider extends PackageServiceProvider { public function configurePackage(Package $package): void { - $migrations = ['create_email_templates_themes_table', 'create_email_templates_table']; + $migrations = [ + 'create_email_templates_themes_table', + 'create_email_templates_table', + 'add_layout_to_email_templates_table', + 'create_email_blocks_table', + ]; if (config('filament-email-templates.multitenancy.enabled')) { $migrations[] = 'add_tenant_to_email_templates_tables'; @@ -47,6 +53,7 @@ public function packageRegistered(): void $this->app->singleton(CreateMailableInterface::class, CreateMailableHelper::class); $this->app->singleton(FormHelperInterface::class, FormHelper::class); $this->app->singleton(TokenRegistry::class); + $this->app->singleton(EmailBlockRegistry::class); $this->app->register(EmailTemplatesEventServiceProvider::class); // Add the binding for TokenReplacementInterface diff --git a/src/Enums/BlockRenderMode.php b/src/Enums/BlockRenderMode.php new file mode 100644 index 0000000..631d1cb --- /dev/null +++ b/src/Enums/BlockRenderMode.php @@ -0,0 +1,14 @@ + ['type' => ..., 'data' => [...]]]) + * to email table rows. Used by the send path, the template preview and the + * Builder block previews, so all three produce the same markup. + */ +class EmailLayoutRenderer +{ + /** @var array */ + protected array $theme = []; + + public function __construct(protected EmailBlockRegistry $registry) {} + + /** + * @param array $colours + */ + public function withTheme(array $colours): static + { + $clone = clone $this; + $clone->theme = $colours; + + return $clone; + } + + /** + * @param array $layout + */ + public function render(array $layout, object|array|null $models, int $depth = 0): string + { + $html = ''; + + foreach (array_values($layout) as $item) { + if (! is_array($item) || ! is_string($item['type'] ?? null) || ! is_array($item['data'] ?? null)) { + continue; + } + + $html .= $this->renderBlock($item['type'], $item['data'], $models, $depth); + } + + return $html; + } + + /** + * @param array $data + */ + public function renderBlock(string $type, array $data, object|array|null $models, int $depth = 0): string + { + $definition = $this->registry->get($type); + + if ($definition === null) { + Log::warning('email layout: unknown block type', ['type' => $type]); + + return ''; + } + + return $definition->render($data, new BlockRenderContext($this, $this->theme, $models, BlockRenderMode::Email, $depth)); + } +} diff --git a/src/Models/EmailBlock.php b/src/Models/EmailBlock.php new file mode 100644 index 0000000..7ac27dc --- /dev/null +++ b/src/Models/EmailBlock.php @@ -0,0 +1,63 @@ + 'array', + 'is_active' => 'boolean', + ]; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + $this->table = config('filament-email-templates.blocks_table_name', 'vb_email_blocks'); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + /** + * Base64 HTML of this block rendered inside the default theme, for the + * preview iframe. + */ + public function getBase64EmailPreviewData(): string + { + $theme = EmailTemplateTheme::query()->where('is_default', true)->firstOrFail(); + $models = EmailTemplate::createEmailPreviewData(); + $data = [ + 'title' => $this->name, + 'preHeaderText' => '', + 'logo' => (new EmailTemplate)->logo, + 'theme' => $theme->colours, + 'colours' => $theme->colours, + 'blocks' => app(EmailLayoutRenderer::class)->withTheme($theme->colours)->render($this->layout ?? [], $models), + ]; + + return base64_encode(view(config('filament-email-templates.template_view_path').'.layout_preview', ['data' => $data])->render()); + } +} diff --git a/src/Models/EmailTemplate.php b/src/Models/EmailTemplate.php index 0f0a62a..323ac2e 100644 --- a/src/Models/EmailTemplate.php +++ b/src/Models/EmailTemplate.php @@ -18,6 +18,7 @@ use Illuminate\Support\Str; use Visualbuilder\EmailTemplates\Database\Factories\EmailTemplateFactory; use Visualbuilder\EmailTemplates\Facades\TokenHelper; +use Visualbuilder\EmailTemplates\Layout\EmailLayoutRenderer; /** * @property int $id @@ -32,6 +33,7 @@ * @property string $preheader * @property string $language * @property string $content + * @property array|null $layout * @property string $created_at * @property string $updated_at * @property string $deleted_at @@ -54,6 +56,7 @@ class EmailTemplate extends Model implements HasMedia 'title', 'preheader', 'content', + 'layout', 'language', 'logo', 'cc', @@ -71,6 +74,7 @@ class EmailTemplate extends Model implements HasMedia 'from' => 'array', 'cc' => 'array', 'bcc' => 'array', + 'layout' => 'array', ]; /** * @var string[] @@ -356,7 +360,7 @@ public function theme() public function getBase64EmailPreviewData() { $data = $this->getEmailPreviewData(); - $content = view($this->view_path, ['data' => $data])->render(); + $content = view($this->renderViewPath(), ['data' => $data])->render(); return base64_encode($content); } @@ -378,7 +382,7 @@ public function getEmailPreviewData() return $content; }; - return [ + $data = [ 'user' => $models->user ?? null, 'content' => TokenHelper::replace($applyOverrides($this->content ?? ''), $models), 'subject' => TokenHelper::replace($applyOverrides($this->subject ?? ''), $models), @@ -387,6 +391,32 @@ public function getEmailPreviewData() 'theme' => $this->theme->colours, 'logo' => $this->logo, ]; + + if ($this->usesLayout()) { + $data['blocks'] = app(EmailLayoutRenderer::class)->withTheme($this->theme->colours ?? [])->render($this->layout, $models); + } + + return $data; + } + + /** + * True when the body is built from blocks (layout) rather than the + * single content field. + */ + public function usesLayout(): bool + { + return is_array($this->layout) && $this->layout !== []; + } + + /** + * The Blade view the email renders with: the block layout view in block + * mode, the template's chosen view otherwise. + */ + public function renderViewPath(): string + { + return $this->usesLayout() + ? config('filament-email-templates.template_view_path').'.layout' + : $this->view_path; } /** diff --git a/src/Resources/EmailBlockResource.php b/src/Resources/EmailBlockResource.php new file mode 100644 index 0000000..4333246 --- /dev/null +++ b/src/Resources/EmailBlockResource.php @@ -0,0 +1,148 @@ +shouldRegisterNavigation(); + } + + public static function getNavigationIcon(): ?string + { + return config('filament-email-templates.navigation.blocks.icon'); + } + + public static function getNavigationGroup(): ?string + { + return EmailTemplatesPlugin::get()->getNavigationGroup(); + } + + public static function getNavigationSort(): ?int + { + return config('filament-email-templates.navigation.blocks.sort'); + } + + public static function getModelLabel(): string + { + return __(config('filament-email-templates.navigation.blocks.label', 'Email Blocks')); + } + + public static function getPluralModelLabel(): string + { + return __(config('filament-email-templates.navigation.blocks.label', 'Email Blocks')); + } + + public static function getCluster(): ?string + { + return static::$cluster ?? (config('filament-email-templates.navigation.blocks.cluster') ?: null); + } + + public static function getSubNavigationPosition(): SubNavigationPosition + { + return config('filament-email-templates.navigation.blocks.position', SubNavigationPosition::Top); + } + + public static function form(Schema $schema): Schema + { + return $schema->schema([ + Section::make() + ->columnSpanFull() + ->schema([ + TextInput::make('name') + ->label(__('Name')) + ->required() + ->maxLength(120) + ->unique(ignoreRecord: true), + TextInput::make('description') + ->label(__('Description')) + ->maxLength(255), + Toggle::make('is_active') + ->label(__('Available in the composer')) + ->default(true), + EmailLayoutBuilder::make('layout', allowSavedBlocks: false), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('name') + ->label(__('Name')) + ->searchable() + ->sortable(), + TextColumn::make('description') + ->label(__('Description')) + ->limit(60), + IconColumn::make('is_active') + ->label(__('Available')) + ->boolean(), + TextColumn::make('updated_at') + ->label(__('Updated')) + ->since() + ->sortable(), + ]) + ->filters([ + TernaryFilter::make('is_active') + ->label(__('Available in the composer')), + TrashedFilter::make(), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make() + ->modalDescription(__('Templates using this block will send without it.')), + RestoreAction::make(), + ]) + ->toolbarActions([ + DeleteBulkAction::make() + ->modalDescription(__('Templates using these blocks will send without them.')), + ]); + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery()->withoutGlobalScopes([SoftDeletingScope::class]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListEmailBlocks::route('/'), + 'create' => Pages\CreateEmailBlock::route('/create'), + 'edit' => Pages\EditEmailBlock::route('/{record}/edit'), + ]; + } +} diff --git a/src/Resources/EmailBlockResource/Pages/CreateEmailBlock.php b/src/Resources/EmailBlockResource/Pages/CreateEmailBlock.php new file mode 100644 index 0000000..93be7f5 --- /dev/null +++ b/src/Resources/EmailBlockResource/Pages/CreateEmailBlock.php @@ -0,0 +1,13 @@ +label(__('Preview')) + ->slideOver() + ->modalSubmitAction(false) + ->modalContent(fn (EmailBlock $record): View => view('vb-email-templates::forms.components.iframe', ['record' => $record])), + DeleteAction::make() + ->modalDescription(__('Templates using this block will send without it.')), + RestoreAction::make(), + ]; + } +} diff --git a/src/Resources/EmailBlockResource/Pages/ListEmailBlocks.php b/src/Resources/EmailBlockResource/Pages/ListEmailBlocks.php new file mode 100644 index 0000000..fe699f8 --- /dev/null +++ b/src/Resources/EmailBlockResource/Pages/ListEmailBlocks.php @@ -0,0 +1,21 @@ +visible(fn () => EmailTemplatesPlugin::get()->hasScreenshotCapture()) ->action(function (EmailTemplate $record): void { $data = $record->getEmailPreviewData(); - $html = view($record->view_path, ['data' => $data])->render(); + $html = view($record->renderViewPath(), ['data' => $data])->render(); $callback = EmailTemplatesPlugin::get()->getScreenshotCaptureCallback(); $result = $callback($html); @@ -210,7 +214,7 @@ public static function table(Table $table): Table foreach ($records as $record) { try { $data = $record->getEmailPreviewData(); - $html = view($record->view_path, ['data' => $data])->render(); + $html = view($record->renderViewPath(), ['data' => $data])->render(); \Visualbuilder\EmailTemplates\Jobs\CaptureEmailScreenshot::dispatch($record, $html); $dispatched++; @@ -321,13 +325,40 @@ public static function form(Schema $schema): Schema ->hint(__('vb-email-templates::email-templates.form-fields-labels.title-hint')) ->maxLength(191), + Toggle::make('use_layout') + ->label(__('Build the body from blocks')) + ->live() + ->dehydrated(false) + ->afterStateHydrated(fn (Toggle $component, ?Model $record) => $component->state($record instanceof EmailTemplate && $record->usesLayout())) + ->hidden(fn (?Model $record): bool => (bool) ($record?->is_quote_template ?? false)) + ->afterStateUpdated(function (bool $state, Get $get, Set $set): void { + // Switching on copies the current body into a first Text block. + if ($state && blank($get('layout'))) { + $set('layout', [ + (string) Str::uuid() => ['type' => 'rich_text', 'data' => ['content' => (string) $get('content')]], + ]); + } + }), + TinyEditor::make('content') ->label(__('vb-email-templates::email-templates.form-fields-labels.content')) ->profile('email-template') ->setCustomConfigs(fn () => [ 'vbtokens_list' => app(\Visualbuilder\EmailTemplates\TokenRegistry::class)->menu(), ]) - ->default("

Dear ##user.first_name##,

"), + ->default("

Dear ##user.first_name##,

") + ->visible(fn (Get $get): bool => ! $get('use_layout')), + + Text::make(__('Header: the theme logo and the title above are always shown.')) + ->key('layout_header') + ->visible(fn (Get $get): bool => (bool) $get('use_layout')), + + EmailLayoutBuilder::make('layout') + ->visible(fn (Get $get): bool => (bool) $get('use_layout')), + + Text::make(__('Footer: the "Need help?" block, footer links and address are always shown. Campaign sends also add an unsubscribe link, which cannot be removed.')) + ->key('layout_footer') + ->visible(fn (Get $get): bool => (bool) $get('use_layout')), Radio::make('logo_type') ->label(__('vb-email-templates::email-templates.form-fields-labels.logo-type')) diff --git a/src/Resources/EmailTemplateResource/Pages/CreateEmailTemplate.php b/src/Resources/EmailTemplateResource/Pages/CreateEmailTemplate.php index f27b35a..1228621 100644 --- a/src/Resources/EmailTemplateResource/Pages/CreateEmailTemplate.php +++ b/src/Resources/EmailTemplateResource/Pages/CreateEmailTemplate.php @@ -10,6 +10,15 @@ class CreateEmailTemplate extends CreateRecord { protected static string $resource = EmailTemplateResource::class; + protected function mutateFormDataBeforeCreate(array $data): array + { + if (! ($this->data['use_layout'] ?? false)) { + $data['layout'] = null; + } + + return $data; + } + protected function handleRecordCreation(array $data): Model { $emailTemplateResource = new EmailTemplateResource(); diff --git a/src/Resources/EmailTemplateResource/Pages/EditEmailTemplate.php b/src/Resources/EmailTemplateResource/Pages/EditEmailTemplate.php index 03f5102..593cd61 100644 --- a/src/Resources/EmailTemplateResource/Pages/EditEmailTemplate.php +++ b/src/Resources/EmailTemplateResource/Pages/EditEmailTemplate.php @@ -46,6 +46,16 @@ protected function mutateFormDataBeforeFill(array $data): array return $data; } + protected function mutateFormDataBeforeSave(array $data): array + { + // Block mode off stores no layout; content is kept, so switching back restores the old body. + if (! ($this->data['use_layout'] ?? false)) { + $data['layout'] = null; + } + + return $data; + } + protected function handleRecordUpdate(Model $record, array $data): Model { $emailTemplateResource = new EmailTemplateResource(); diff --git a/src/Resources/Schemas/EmailLayoutBuilder.php b/src/Resources/Schemas/EmailLayoutBuilder.php new file mode 100644 index 0000000..60503b9 --- /dev/null +++ b/src/Resources/Schemas/EmailLayoutBuilder.php @@ -0,0 +1,42 @@ +all() : $registry->nestable(); + + return Builder::make($name) + ->label('Email body') + ->addActionLabel('Add block') + ->blockNumbers(false) + ->blockPreviews() + ->reorderableWithButtons() + ->collapsible() + ->minItems(1) + ->blocks(collect($definitions)->map( + fn (EmailBlockDefinition $definition): EmailBuilderBlock => EmailBuilderBlock::make($definition->name()) + ->label(fn (?array $state): string => filled($summary = $definition->summary($state ?? [])) + ? $definition->label().': '.Str::limit($summary, 50) + : $definition->label()) + ->icon($definition->icon()) + ->preview('vb-email-templates::forms.block-preview') + ->schema($definition->schema()), + )->values()->all()) + ->columnSpanFull(); + } +} diff --git a/src/Traits/BuildGenericEmail.php b/src/Traits/BuildGenericEmail.php index e793d86..df1541f 100644 --- a/src/Traits/BuildGenericEmail.php +++ b/src/Traits/BuildGenericEmail.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Log; use Visualbuilder\EmailTemplates\Facades\TokenHelper; +use Visualbuilder\EmailTemplates\Layout\EmailLayoutRenderer; use Visualbuilder\EmailTemplates\Models\EmailTemplate; trait BuildGenericEmail @@ -44,6 +45,14 @@ public function build() 'logo' => $this->emailTemplate->logo, ]; + // Block mode: the body is the rendered layout; content is still set but unused by layout.blade.php. + if ($this->emailTemplate->usesLayout()) { + $data['blocks'] = app(EmailLayoutRenderer::class) + ->withTheme($this->emailTemplate->theme->colours ?? []) + ->render($this->emailTemplate->layout, $this); + $data['unsubscribeUrl'] = property_exists($this, 'unsubscribeUrl') ? ($this->unsubscribeUrl ?? null) : null; + } + if(is_array($this->emailTemplate->cc)&&count($this->emailTemplate->cc)){ $this->cc($this->emailTemplate->cc); }; @@ -53,7 +62,7 @@ public function build() }; return $this->from($this->emailTemplate->from['email'], $this->emailTemplate->from['name']) - ->view($this->emailTemplate->view_path) + ->view($this->emailTemplate->renderViewPath()) ->subject(TokenHelper::replace($this->emailTemplate->subject, $this)) ->to($this->sendTo) ->with(['data' => $data]); diff --git a/tests/AdminPanelProvider.php b/tests/AdminPanelProvider.php index f1dd503..f5d3aa4 100644 --- a/tests/AdminPanelProvider.php +++ b/tests/AdminPanelProvider.php @@ -16,6 +16,7 @@ use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; use Visualbuilder\EmailTemplates\EmailTemplatesPlugin; +use Visualbuilder\EmailTemplates\Resources\EmailBlockResource; use Visualbuilder\EmailTemplates\Resources\EmailTemplateResource; use Visualbuilder\EmailTemplates\Resources\EmailTemplateThemeResource; @@ -34,6 +35,7 @@ public function panel(Panel $panel): Panel ->resources([ EmailTemplateResource::class, EmailTemplateThemeResource::class, + EmailBlockResource::class, ]) ->middleware([ EncryptCookies::class, diff --git a/tests/EmailBlockRegistryTest.php b/tests/EmailBlockRegistryTest.php new file mode 100644 index 0000000..af6e0d6 --- /dev/null +++ b/tests/EmailBlockRegistryTest.php @@ -0,0 +1,75 @@ +all(); + + expect(array_keys($all))->toBe(['rich_text', 'hero', 'two_column', 'button', 'image', 'divider', 'saved_block']) + ->and(array_map(fn ($definition) => $definition::class, array_values($all)))->toBe(EmailBlockRegistry::DEFAULT_TYPES) + ->and(EmailBlockRegistry::DEFAULT_TYPES)->toBe([ + RichTextBlock::class, + HeroBlock::class, + TwoColumnBlock::class, + ButtonBlock::class, + ImageBlock::class, + DividerBlock::class, + SavedBlock::class, + ]); +}); + +it('falls back to the default types when the config key is empty', function () { + config(['filament-email-templates.block_types' => []]); + + expect(array_keys((new EmailBlockRegistry)->all()))->toHaveCount(7); +}); + +it('excludes saved blocks from the nestable types', function () { + $nestable = app(EmailBlockRegistry::class)->nestable(); + + expect($nestable)->not->toHaveKey('saved_block') + ->and(array_keys($nestable))->toBe(['rich_text', 'hero', 'two_column', 'button', 'image', 'divider']); +}); + +it('adds a host block registered at runtime last and renders it', function () { + $registry = app(EmailBlockRegistry::class); + $registry->all(); + + $registry->register(QuoteBlock::class); + + expect(array_key_last($registry->all()))->toBe('quote') + ->and(app(EmailLayoutRenderer::class)->render([['type' => 'quote', 'data' => []]], null)) + ->toBe('
quote block