fixed main frontend

This commit is contained in:
Mark Gregory
2026-07-27 11:45:03 +01:00
parent 16e7c6d010
commit 4a32be068c
18 changed files with 1471 additions and 132 deletions
@@ -1,4 +1,5 @@
<?php
namespace Modules\Content\Console;
use Illuminate\Console\Command;
@@ -16,49 +17,132 @@ final class InstallSaaslandContent extends Command
public function handle(): int
{
$requiredTables = ['content_types', 'content_documents', 'page_content'];
$missingTables = array_values(array_filter(
$requiredTables,
static fn (string $table): bool => ! Schema::hasTable($table),
));
$missingTables = array_values(array_filter($requiredTables, static fn (string $table): bool => ! Schema::hasTable($table)));
if ($missingTables !== []) {
$this->components->error('Content composition tables are missing: '.implode(', ', $missingTables).'.');
$this->components->info('Run php artisan migrate before installing the Saasland composition.');
return self::FAILURE;
}
DB::transaction(function (): void {
if ($this->option('force')) PageContent::query()->where('page_key','home')->delete();
$definitions = $this->definitions();
$order = ['site-header'=>['header',10], 'hero'=>['body',10], 'feature-grid'=>['body',20], 'product-feed'=>['body',30], 'testimonial-feed'=>['body',40], 'team-feed'=>['body',50], 'blog-feed'=>['body',60], 'news-feed'=>['body',70], 'faq-feed'=>['body',80], 'newsletter-form'=>['body',90], 'call-to-action'=>['body',100], 'site-footer'=>['footer',10]];
foreach ($definitions as $key => $definition) {
$type = ContentType::query()->updateOrCreate(['key'=>$key], $definition['type']);
$document = ContentDocument::query()->updateOrCreate(['slug'=>'saasland-'.$key], ['content_type_id'=>$type->id,'title'=>$definition['title'],'data'=>$definition['data'],'status'=>'published','published_at'=>now()]);
[$region,$sort] = $order[$key];
PageContent::query()->updateOrCreate(['page_key'=>'home','region'=>$region,'sort_order'=>$sort], ['content_document_id'=>$document->id,'is_enabled'=>true]);
if ($this->option('force')) {
PageContent::query()->where('page_key', 'home')->delete();
}
$order = [
'site-header' => ['header', 10], 'hero' => ['body', 10], 'feature-grid' => ['body', 20],
'product-feed' => ['body', 30], 'testimonial-feed' => ['body', 40], 'team-feed' => ['body', 50],
'blog-feed' => ['body', 60], 'news-feed' => ['body', 70], 'faq-feed' => ['body', 80],
'newsletter-form' => ['body', 90], 'call-to-action' => ['body', 100], 'site-footer' => ['footer', 10],
];
foreach ($this->definitions() as $key => $definition) {
$type = ContentType::query()->updateOrCreate(['key' => $key], $definition['type']);
$document = ContentDocument::query()->firstOrCreate(
['slug' => 'saasland-'.$key],
[
'content_type_id' => $type->id,
'title' => $definition['title'],
'data' => $definition['data'],
'status' => 'published',
'published_at' => now(),
]
);
if ((int) $document->content_type_id !== (int) $type->id) {
$document->forceFill(['content_type_id' => $type->id])->save();
}
[$region, $sort] = $order[$key];
PageContent::query()->updateOrCreate(
['page_key' => 'home', 'region' => $region, 'sort_order' => $sort],
['content_document_id' => $document->id, 'is_enabled' => true]
);
}
});
$this->components->info('Saasland content composition installed.');
$this->components->info('Saasland content composition installed with editable schemas.');
return self::SUCCESS;
}
private function definitions(): array
{
$type = fn(string $name,string $renderer,array $regions) => ['name'=>$name,'renderer'=>$renderer,'allowed_regions'=>$regions,'is_system'=>true,'is_active'=>true,'schema'=>[]];
$field = static fn (string $key, string $label, string $type = 'text', array $extra = []): array => array_merge(compact('key', 'label', 'type'), $extra);
$type = static fn (string $name, string $renderer, array $regions, array $schema): array => [
'name' => $name, 'renderer' => $renderer, 'allowed_regions' => $regions,
'is_system' => true, 'is_active' => true, 'schema' => $schema,
];
$section = [$field('enabled', 'Show this section', 'boolean', ['default' => true])];
$feed = array_merge($section, [
$field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'),
$field('limit', 'Maximum items', 'number', ['min' => 1, 'max' => 50]),
$field('include_ids', 'Only show record IDs', 'csv', ['help' => 'Optional comma-separated IDs. Leave blank to use the latest records.']),
$field('exclude_ids', 'Exclude record IDs', 'csv', ['help' => 'Optional comma-separated IDs to hide from this section.']),
]);
return [
'site-header'=>['title'=>'Main website header','type'=>$type('Site header','site-header',['header']),'data'=>['show_login'=>true,'show_registration'=>true,'sticky'=>true]],
'hero'=>['title'=>'Homepage hero','type'=>$type('Hero','hero',['body']),'data'=>['eyebrow'=>'A better way to run your business','heading'=>'Transform Your Business With Our SaaS Solution','description'=>'Streamline operations, boost productivity, and improve customer satisfaction with one connected platform built for modern teams.','primary_label'=>'Get started free','primary_url'=>'/register','secondary_label'=>'Book demo','secondary_url'=>'#contact','show_metrics'=>true]],
'feature-grid'=>['title'=>'Core features','type'=>$type('Feature grid','feature-grid',['body']),'data'=>['eyebrow'=>'Powerful features','heading'=>'Everything your team needs to grow','description'=>'Planning, collaboration, reporting, publishing, commerce and customer insight in one extensible platform.','items'=>[['title'=>'Smart automation','description'=>'Remove repetitive work and delays.'],['title'=>'Real-time analytics','description'=>'Turn activity into useful business insight.'],['title'=>'Content operations','description'=>'Create, review and publish confidently.'],['title'=>'Secure by design','description'=>'Protect users and data with dependable controls.'],['title'=>'Flexible integrations','description'=>'Connect the tools your organisation already uses.'],['title'=>'Modular architecture','description'=>'Activate only the capabilities each site needs.']]]],
'product-feed'=>['title'=>'Pricing plans','type'=>$type('Product feed','product-feed',['body']),'data'=>['eyebrow'=>'Simple pricing','heading'=>'Choose a plan that fits','description'=>'Plans are managed from Products & Pricing in the CMS.','limit'=>6]],
'testimonial-feed'=>['title'=>'Customer stories','type'=>$type('Testimonial feed','testimonial-feed',['body']),'data'=>['eyebrow'=>'Customer stories','heading'=>'Trusted by ambitious teams','limit'=>6]],
'team-feed'=>['title'=>'Team','type'=>$type('Team feed','team-feed',['body']),'data'=>['eyebrow'=>'Our team','heading'=>'People behind the platform','limit'=>8]],
'blog-feed'=>['title'=>'Latest blog posts','type'=>$type('Blog feed','blog-feed',['body']),'data'=>['eyebrow'=>'Insights','heading'=>'Latest from the blog','limit'=>3]],
'news-feed'=>['title'=>'Latest news','type'=>$type('News feed','news-feed',['body']),'data'=>['eyebrow'=>'Newsroom','heading'=>'Latest news','limit'=>3]],
'faq-feed'=>['title'=>'Frequently asked questions','type'=>$type('FAQ feed','faq-feed',['body']),'data'=>['eyebrow'=>'FAQ','heading'=>'Answers before you begin','limit'=>12]],
'newsletter-form'=>['title'=>'Newsletter signup','type'=>$type('Newsletter form','newsletter-form',['body']),'data'=>['eyebrow'=>'Newsletter','heading'=>'Useful updates, no noise','description'=>'Receive product news, publishing guidance, and practical platform updates.','button_label'=>'Subscribe']],
'call-to-action'=>['title'=>'Homepage final call to action','type'=>$type('Call to action','call-to-action',['body']),'data'=>['heading'=>'Ready to transform your business?','description'=>'Start building a faster, more connected operation today.','button_label'=>'Get started free','button_url'=>'/register']],
'site-footer'=>['title'=>'Main website footer','type'=>$type('Site footer','site-footer',['footer']),'data'=>['description'=>'One connected platform for modern teams.','show_social_links'=>true,'show_back_to_top'=>true]],
'site-header' => [
'title' => 'Main website header',
'type' => $type('Site header', 'site-header', ['header'], [
...$section, $field('show_navigation', 'Show navigation', 'boolean', ['default' => true]), $field('show_login', 'Show login', 'boolean', ['default' => true]),
$field('show_registration', 'Show registration', 'boolean', ['default' => true]), $field('login_label', 'Login label'),
$field('registration_label', 'Registration label'), $field('sticky', 'Sticky header', 'boolean', ['default' => true]),
]),
'data' => ['enabled' => true, 'show_navigation' => true, 'show_login' => true, 'show_registration' => true, 'login_label' => 'Log in', 'registration_label' => 'Get started', 'sticky' => true],
],
'hero' => [
'title' => 'Homepage hero',
'type' => $type('Hero', 'hero', ['body'], [
...$section, $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'),
$field('primary_label', 'Primary button label'), $field('primary_url', 'Primary button URL', 'url'),
$field('secondary_label', 'Secondary button label'), $field('secondary_url', 'Secondary button URL', 'url'),
$field('show_metrics', 'Show metrics panel', 'boolean', ['default' => true]),
$field('metrics', 'Metrics', 'repeater', ['fields' => [
$field('enabled', 'Show item', 'boolean', ['default' => true]), $field('label', 'Label'), $field('value', 'Value'), $field('change', 'Change'), $field('height', 'Chart height (%)', 'number', ['min' => 0, 'max' => 100]),
]]),
]),
'data' => ['enabled' => true, 'eyebrow' => 'A better way to run your business', 'heading' => 'Transform Your Business With Our SaaS Solution', 'description' => 'Streamline operations, boost productivity, and improve customer satisfaction with one connected platform built for modern teams.', 'primary_label' => 'Get started free', 'primary_url' => '/register', 'secondary_label' => 'Book demo', 'secondary_url' => '#contact', 'show_metrics' => true, 'metrics' => [
['enabled' => true, 'label' => 'Monthly revenue', 'value' => '£48,240', 'change' => '+18.4%', 'height' => 62],
['enabled' => true, 'label' => 'Active users', 'value' => '12,842', 'change' => '+9.2%', 'height' => 78],
['enabled' => true, 'label' => 'Conversion', 'value' => '7.8%', 'change' => '+2.1%', 'height' => 92],
]],
],
'feature-grid' => [
'title' => 'Core features',
'type' => $type('Feature grid', 'feature-grid', ['body'], [
...$section, $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'),
$field('items', 'Features', 'repeater', ['fields' => [$field('enabled', 'Show item', 'boolean', ['default' => true]), $field('title', 'Title'), $field('description', 'Description', 'textarea')]]),
]),
'data' => ['enabled' => true, 'eyebrow' => 'Powerful features', 'heading' => 'Everything your team needs to grow', 'description' => 'Planning, collaboration, reporting, publishing, commerce and customer insight in one extensible platform.', 'items' => [
['enabled' => true, 'title' => 'Smart automation', 'description' => 'Remove repetitive work and delays.'],
['enabled' => true, 'title' => 'Real-time analytics', 'description' => 'Turn activity into useful business insight.'],
['enabled' => true, 'title' => 'Content operations', 'description' => 'Create, review and publish confidently.'],
['enabled' => true, 'title' => 'Secure by design', 'description' => 'Protect users and data with dependable controls.'],
['enabled' => true, 'title' => 'Flexible integrations', 'description' => 'Connect the tools your organisation already uses.'],
['enabled' => true, 'title' => 'Modular architecture', 'description' => 'Activate only the capabilities each site needs.'],
]],
],
'product-feed' => ['title' => 'Pricing plans', 'type' => $type('Product feed', 'product-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Simple pricing', 'heading' => 'Choose a plan that fits', 'description' => 'Plans are managed from Products & Pricing in the CMS.', 'limit' => 6, 'include_ids' => '', 'exclude_ids' => '']],
'testimonial-feed' => ['title' => 'Customer stories', 'type' => $type('Testimonial feed', 'testimonial-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Customer stories', 'heading' => 'Trusted by ambitious teams', 'description' => '', 'limit' => 6, 'include_ids' => '', 'exclude_ids' => '']],
'team-feed' => ['title' => 'Team', 'type' => $type('Team feed', 'team-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Our team', 'heading' => 'People behind the platform', 'description' => '', 'limit' => 8, 'include_ids' => '', 'exclude_ids' => '']],
'blog-feed' => ['title' => 'Latest blog posts', 'type' => $type('Blog feed', 'blog-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Insights', 'heading' => 'Latest from the blog', 'description' => '', 'limit' => 3, 'include_ids' => '', 'exclude_ids' => '']],
'news-feed' => ['title' => 'Latest news', 'type' => $type('News feed', 'news-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Newsroom', 'heading' => 'Latest news', 'description' => '', 'limit' => 3, 'include_ids' => '', 'exclude_ids' => '']],
'faq-feed' => ['title' => 'Frequently asked questions', 'type' => $type('FAQ feed', 'faq-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'FAQ', 'heading' => 'Answers before you begin', 'description' => '', 'limit' => 12, 'include_ids' => '', 'exclude_ids' => '']],
'newsletter-form' => [
'title' => 'Newsletter signup',
'type' => $type('Newsletter form', 'newsletter-form', ['body'], [...$section, $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'), $field('placeholder', 'Email placeholder'), $field('button_label', 'Button label')]),
'data' => ['enabled' => true, 'eyebrow' => 'Newsletter', 'heading' => 'Useful updates, no noise', 'description' => 'Receive product news, publishing guidance, and practical platform updates.', 'placeholder' => 'you@example.com', 'button_label' => 'Subscribe'],
],
'call-to-action' => [
'title' => 'Homepage final call to action',
'type' => $type('Call to action', 'call-to-action', ['body'], [...$section, $field('anchor', 'Section anchor'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'), $field('button_label', 'Button label'), $field('button_url', 'Button URL', 'url')]),
'data' => ['enabled' => true, 'anchor' => 'contact', 'heading' => 'Ready to transform your business?', 'description' => 'Start building a faster, more connected operation today.', 'button_label' => 'Get started free', 'button_url' => '/register'],
],
'site-footer' => [
'title' => 'Main website footer',
'type' => $type('Site footer', 'site-footer', ['footer'], [...$section, $field('description', 'Description', 'textarea'), $field('show_navigation', 'Show footer navigation', 'boolean'), $field('show_account_links', 'Show account links', 'boolean', ['default' => true]), $field('show_social_links', 'Show social links', 'boolean', ['default' => true]), $field('show_back_to_top', 'Show back-to-top link', 'boolean', ['default' => true]), $field('copyright', 'Copyright text')]),
'data' => ['enabled' => true, 'description' => 'One connected platform for modern teams.', 'show_navigation' => true, 'show_account_links' => true, 'show_social_links' => true, 'show_back_to_top' => true, 'copyright' => 'All rights reserved.'],
],
];
}
}
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace Modules\Content\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\Content\Models\ContentDocument;
use Modules\Content\Models\ContentType;
use Modules\Content\Models\PageContent;
final class InstallSaaslandContent extends Command
{
protected $signature = 'content:install-saasland {--force : Replace the existing home composition}';
protected $description = 'Install editable Saasland header, body and footer content documents.';
public function handle(): int
{
$requiredTables = ['content_types', 'content_documents', 'page_content'];
$missingTables = array_values(array_filter($requiredTables, static fn (string $table): bool => ! Schema::hasTable($table)));
if ($missingTables !== []) {
$this->components->error('Content composition tables are missing: '.implode(', ', $missingTables).'.');
$this->components->info('Run php artisan migrate before installing the Saasland composition.');
return self::FAILURE;
}
DB::transaction(function (): void {
if ($this->option('force')) {
PageContent::query()->where('page_key', 'home')->delete();
}
$order = [
'site-header' => ['header', 10], 'hero' => ['body', 10], 'feature-grid' => ['body', 20],
'product-feed' => ['body', 30], 'testimonial-feed' => ['body', 40], 'team-feed' => ['body', 50],
'blog-feed' => ['body', 60], 'news-feed' => ['body', 70], 'faq-feed' => ['body', 80],
'newsletter-form' => ['body', 90], 'call-to-action' => ['body', 100], 'site-footer' => ['footer', 10],
];
foreach ($this->definitions() as $key => $definition) {
$type = ContentType::query()->updateOrCreate(['key' => $key], $definition['type']);
$document = ContentDocument::query()->firstOrCreate(
['slug' => 'saasland-'.$key],
[
'content_type_id' => $type->id,
'title' => $definition['title'],
'data' => $definition['data'],
'status' => 'published',
'published_at' => now(),
]
);
if ((int) $document->content_type_id !== (int) $type->id) {
$document->forceFill(['content_type_id' => $type->id])->save();
}
[$region, $sort] = $order[$key];
PageContent::query()->updateOrCreate(
['page_key' => 'home', 'region' => $region, 'sort_order' => $sort],
['content_document_id' => $document->id, 'is_enabled' => true]
);
}
});
$this->components->info('Saasland content composition installed with editable schemas.');
return self::SUCCESS;
}
private function definitions(): array
{
$field = static fn (string $key, string $label, string $type = 'text', array $extra = []): array => array_merge(compact('key', 'label', 'type'), $extra);
$type = static fn (string $name, string $renderer, array $regions, array $schema): array => [
'name' => $name, 'renderer' => $renderer, 'allowed_regions' => $regions,
'is_system' => true, 'is_active' => true, 'schema' => $schema,
];
$section = [$field('enabled', 'Show this section', 'boolean', ['default' => true])];
$feed = array_merge($section, [
$field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'),
$field('limit', 'Maximum items', 'number', ['min' => 1, 'max' => 50]),
$field('include_ids', 'Only show record IDs', 'csv', ['help' => 'Optional comma-separated IDs. Leave blank to use the latest records.']),
$field('exclude_ids', 'Exclude record IDs', 'csv', ['help' => 'Optional comma-separated IDs to hide from this section.']),
]);
return [
'site-header' => [
'title' => 'Main website header',
'type' => $type('Site header', 'site-header', ['header'], [
...$section, $field('show_navigation', 'Show navigation', 'boolean', ['default' => true]), $field('show_login', 'Show login', 'boolean', ['default' => true]),
$field('show_registration', 'Show registration', 'boolean', ['default' => true]), $field('login_label', 'Login label'),
$field('registration_label', 'Registration label'), $field('sticky', 'Sticky header', 'boolean', ['default' => true]),
]),
'data' => ['enabled' => true, 'show_navigation' => true, 'show_login' => true, 'show_registration' => true, 'login_label' => 'Log in', 'registration_label' => 'Get started', 'sticky' => true],
],
'hero' => [
'title' => 'Homepage hero',
'type' => $type('Hero', 'hero', ['body'], [
...$section, $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'),
$field('primary_label', 'Primary button label'), $field('primary_url', 'Primary button URL', 'url'),
$field('secondary_label', 'Secondary button label'), $field('secondary_url', 'Secondary button URL', 'url'),
$field('show_metrics', 'Show metrics panel', 'boolean', ['default' => true]),
$field('metrics', 'Metrics', 'repeater', ['fields' => [
$field('enabled', 'Show item', 'boolean', ['default' => true]), $field('label', 'Label'), $field('value', 'Value'), $field('change', 'Change'), $field('height', 'Chart height (%)', 'number', ['min' => 0, 'max' => 100]),
]]),
]),
'data' => ['enabled' => true, 'eyebrow' => 'A better way to run your business', 'heading' => 'Transform Your Business With Our SaaS Solution', 'description' => 'Streamline operations, boost productivity, and improve customer satisfaction with one connected platform built for modern teams.', 'primary_label' => 'Get started free', 'primary_url' => '/register', 'secondary_label' => 'Book demo', 'secondary_url' => '#contact', 'show_metrics' => true, 'metrics' => [
['enabled' => true, 'label' => 'Monthly revenue', 'value' => '£48,240', 'change' => '+18.4%', 'height' => 62],
['enabled' => true, 'label' => 'Active users', 'value' => '12,842', 'change' => '+9.2%', 'height' => 78],
['enabled' => true, 'label' => 'Conversion', 'value' => '7.8%', 'change' => '+2.1%', 'height' => 92],
]],
],
'feature-grid' => [
'title' => 'Core features',
'type' => $type('Feature grid', 'feature-grid', ['body'], [
...$section, $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'),
$field('items', 'Features', 'repeater', ['fields' => [$field('enabled', 'Show item', 'boolean', ['default' => true]), $field('title', 'Title'), $field('description', 'Description', 'textarea')]]),
]),
'data' => ['enabled' => true, 'eyebrow' => 'Powerful features', 'heading' => 'Everything your team needs to grow', 'description' => 'Planning, collaboration, reporting, publishing, commerce and customer insight in one extensible platform.', 'items' => [
['enabled' => true, 'title' => 'Smart automation', 'description' => 'Remove repetitive work and delays.'],
['enabled' => true, 'title' => 'Real-time analytics', 'description' => 'Turn activity into useful business insight.'],
['enabled' => true, 'title' => 'Content operations', 'description' => 'Create, review and publish confidently.'],
['enabled' => true, 'title' => 'Secure by design', 'description' => 'Protect users and data with dependable controls.'],
['enabled' => true, 'title' => 'Flexible integrations', 'description' => 'Connect the tools your organisation already uses.'],
['enabled' => true, 'title' => 'Modular architecture', 'description' => 'Activate only the capabilities each site needs.'],
]],
],
'product-feed' => ['title' => 'Pricing plans', 'type' => $type('Product feed', 'product-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Simple pricing', 'heading' => 'Choose a plan that fits', 'description' => 'Plans are managed from Products & Pricing in the CMS.', 'limit' => 6, 'include_ids' => '', 'exclude_ids' => '']],
'testimonial-feed' => ['title' => 'Customer stories', 'type' => $type('Testimonial feed', 'testimonial-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Customer stories', 'heading' => 'Trusted by ambitious teams', 'description' => '', 'limit' => 6, 'include_ids' => '', 'exclude_ids' => '']],
'team-feed' => ['title' => 'Team', 'type' => $type('Team feed', 'team-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Our team', 'heading' => 'People behind the platform', 'description' => '', 'limit' => 8, 'include_ids' => '', 'exclude_ids' => '']],
'blog-feed' => ['title' => 'Latest blog posts', 'type' => $type('Blog feed', 'blog-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Insights', 'heading' => 'Latest from the blog', 'description' => '', 'limit' => 3, 'include_ids' => '', 'exclude_ids' => '']],
'news-feed' => ['title' => 'Latest news', 'type' => $type('News feed', 'news-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Newsroom', 'heading' => 'Latest news', 'description' => '', 'limit' => 3, 'include_ids' => '', 'exclude_ids' => '']],
'faq-feed' => ['title' => 'Frequently asked questions', 'type' => $type('FAQ feed', 'faq-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'FAQ', 'heading' => 'Answers before you begin', 'description' => '', 'limit' => 12, 'include_ids' => '', 'exclude_ids' => '']],
'newsletter-form' => [
'title' => 'Newsletter signup',
'type' => $type('Newsletter form', 'newsletter-form', ['body'], [...$section, $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'), $field('placeholder', 'Email placeholder'), $field('button_label', 'Button label')]),
'data' => ['enabled' => true, 'eyebrow' => 'Newsletter', 'heading' => 'Useful updates, no noise', 'description' => 'Receive product news, publishing guidance, and practical platform updates.', 'placeholder' => 'you@example.com', 'button_label' => 'Subscribe'],
],
'call-to-action' => [
'title' => 'Homepage final call to action',
'type' => $type('Call to action', 'call-to-action', ['body'], [...$section, $field('anchor', 'Section anchor'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'), $field('button_label', 'Button label'), $field('button_url', 'Button URL', 'url')]),
'data' => ['enabled' => true, 'anchor' => 'contact', 'heading' => 'Ready to transform your business?', 'description' => 'Start building a faster, more connected operation today.', 'button_label' => 'Get started free', 'button_url' => '/register'],
],
'site-footer' => [
'title' => 'Main website footer',
'type' => $type('Site footer', 'site-footer', ['footer'], [...$section, $field('description', 'Description', 'textarea'), $field('show_navigation', 'Show footer navigation', 'boolean'), $field('show_account_links', 'Show account links', 'boolean', ['default' => true]), $field('show_social_links', 'Show social links', 'boolean', ['default' => true]), $field('show_back_to_top', 'Show back-to-top link', 'boolean', ['default' => true]), $field('copyright', 'Copyright text')]),
'data' => ['enabled' => true, 'description' => 'One connected platform for modern teams.', 'show_navigation' => true, 'show_account_links' => true, 'show_social_links' => true, 'show_back_to_top' => true, 'copyright' => 'All rights reserved.'],
],
];
}
}
@@ -1,120 +1,399 @@
<x-layouts.app :title="$document->exists ? $document->title : __('Create content document')">
<div class="mx-auto max-w-6xl space-y-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div class="flex items-center gap-2 text-sm text-zinc-500">
<a href="{{ route('content.index') }}" class="hover:text-zinc-900 dark:hover:text-white">Content documents</a>
<span>/</span>
<span>{{ $document->exists ? 'Edit' : 'Create' }}</span>
<div class="mx-auto max-w-7xl space-y-6" id="content-editor">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<a href="{{ route('content.index') }}" class="text-sm text-zinc-500 hover:underline">
Content documents
</a>
<h1 class="mt-1 text-2xl font-semibold">
{{ $document->exists ? $document->title : 'Create content document' }}
</h1>
<p class="mt-1 text-sm text-zinc-500">
Edit every frontend field below. JSON is retained only as an emergency advanced tool.
</p>
</div>
<h1 class="mt-1 text-2xl font-semibold">{{ $document->exists ? $document->title : 'Create content document' }}</h1>
<p class="mt-1 text-sm text-zinc-500">Structured, reusable content that can be placed into one or more page regions.</p>
@if ($document->exists)
<div class="flex flex-wrap gap-2">
<form method="POST" action="{{ route('content.documents.duplicate', $document) }}">
@csrf
<flux:button type="submit" size="sm">Create independent copy</flux:button>
</form>
<form
method="POST"
action="{{ route('content.documents.destroy', $document) }}"
onsubmit="return confirm('Delete this unplaced document permanently?')"
>
@csrf
@method('DELETE')
<flux:button type="submit" size="sm" variant="danger">Delete</flux:button>
</form>
</div>
@endif
</div>
@if($document->exists)
<div class="flex flex-wrap gap-2">
<form method="POST" action="{{ route('content.documents.duplicate', $document) }}">
@csrf
<flux:button type="submit" size="sm">Create independent copy</flux:button>
</form>
<form method="POST" action="{{ route('content.documents.destroy', $document) }}" onsubmit="return confirm('Delete this document? This is only allowed when it is not used by any page.')">
@csrf @method('DELETE')
<flux:button type="submit" size="sm" variant="danger">Delete</flux:button>
</form>
@if (session('status'))
<div class="rounded-xl border border-green-300 bg-green-50 p-4 text-sm text-green-800">
{{ session('status') }}
</div>
@endif
@if ($errors->any())
<div class="rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-800">
<ul class="list-disc pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
@if ($document->exists && $document->placements->count() > 1)
<div class="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900">
<strong>Shared document.</strong>
Changes affect all {{ $document->placements->count() }} placements.
</div>
@endif
<form
method="POST"
action="{{ $document->exists ? route('content.documents.update', $document) : route('content.documents.store') }}"
class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_21rem]"
id="document-form"
>
@csrf
@if ($document->exists)
@method('PUT')
@endif
<div class="space-y-6">
<flux:card class="space-y-5">
<h2 class="text-lg font-semibold">Document</h2>
<flux:input name="title" label="Internal title" value="{{ old('title', $document->title) }}" required />
<flux:input name="slug" label="Slug" value="{{ old('slug', $document->slug) }}" />
<flux:select name="content_type_id" label="Content type" id="content-type-select" required>
@foreach ($types as $type)
<option
value="{{ $type->id }}"
@selected((int) old('content_type_id', $selectedType?->id) === $type->id)
>
{{ $type->name }}
</option>
@endforeach
</flux:select>
</flux:card>
<flux:card class="space-y-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold">Frontend content</h2>
<p class="text-sm text-zinc-500">
Switches take effect when this document is saved.
</p>
</div>
<span id="schema-status" class="rounded-full bg-zinc-100 px-3 py-1 text-xs text-zinc-600"></span>
</div>
<div id="schema-fields" class="space-y-5"></div>
<details class="rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<summary class="cursor-pointer text-sm font-medium">Advanced JSON (emergency use only)</summary>
<p class="mt-2 text-xs text-zinc-500">
Normal editing should be performed with the controls above.
</p>
<textarea
id="data-json"
class="mt-4 min-h-80 w-full rounded-xl border border-zinc-300 bg-white p-4 font-mono text-sm dark:border-zinc-700 dark:bg-zinc-950"
name="data_json"
spellcheck="false"
>{{ old('data_json', json_encode($document->data ?: [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) }}</textarea>
</details>
</flux:card>
</div>
<aside class="space-y-6">
<flux:card class="space-y-4">
<h2 class="text-lg font-semibold">Publishing</h2>
<flux:select name="status" label="Status">
<option value="draft" @selected(old('status', $document->status ?: 'draft') === 'draft')>Draft</option>
<option value="published" @selected(old('status', $document->status) === 'published')>Published</option>
</flux:select>
<flux:input
type="datetime-local"
name="published_at"
label="Publish at"
value="{{ old('published_at', $document->published_at?->format('Y-m-d\TH:i')) }}"
/>
<flux:input
type="datetime-local"
name="expires_at"
label="Expire at"
value="{{ old('expires_at', $document->expires_at?->format('Y-m-d\TH:i')) }}"
/>
<flux:button type="submit" variant="primary" class="w-full">Save document</flux:button>
</flux:card>
@if ($document->exists)
<flux:card>
<h2 class="text-lg font-semibold">Usage</h2>
<div class="mt-3 space-y-2">
@forelse ($document->placements as $placement)
<a
class="block rounded-lg border p-3 text-sm"
href="{{ route('content.pages.edit', $placement->page_key) }}"
>
<strong>{{ ucfirst($placement->page_key) }}</strong>
<span class="block text-xs uppercase text-zinc-500">{{ $placement->region }}</span>
</a>
@empty
<p class="text-sm text-zinc-500">Not placed on a page.</p>
@endforelse
</div>
</flux:card>
@endif
</aside>
</form>
</div>
@if(session('status'))
<div class="rounded-xl border border-green-300 bg-green-50 p-4 text-sm text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200">{{ session('status') }}</div>
@endif
@if($errors->any())
<div class="rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200">
<strong>The document could not be saved.</strong>
<ul class="mt-2 list-disc pl-5">@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul>
</div>
@endif
<script>
document.addEventListener('DOMContentLoaded', () => {
const rawSchemas = @json($types->mapWithKeys(fn ($type) => [(string) $type->id => $type->schema ?? []]));
const dataField = document.getElementById('data-json');
const host = document.getElementById('schema-fields');
const typeSelect = document.getElementById('content-type-select');
const status = document.getElementById('schema-status');
const form = document.getElementById('document-form');
@if($document->exists && $document->placements->count() > 1)
<div class="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
<strong>Shared document.</strong> This document is used in {{ $document->placements->count() }} page placements. Changes will appear everywhere it is published. Use “Create independent copy” for page-specific content.
</div>
@endif
let data = parseObject(dataField.value);
<form method="POST" action="{{ $document->exists ? route('content.documents.update', $document) : route('content.documents.store') }}" class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]">
@csrf
@if($document->exists) @method('PUT') @endif
function parseObject(value) {
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
try {
const parsed = JSON.parse(value || '{}');
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch (_) {
return {};
}
}
<div class="space-y-6">
<flux:card class="space-y-5">
<h2 class="text-lg font-semibold">Document</h2>
<flux:input name="title" label="Title" value="{{ old('title', $document->title) }}" required />
<flux:input name="slug" label="Slug" value="{{ old('slug', $document->slug) }}" description="Leave empty to generate it from the title." />
<flux:select name="content_type_id" label="Content type" required>
@foreach($types as $type)
<option value="{{ $type->id }}" @selected((int) old('content_type_id', $selectedType?->id) === $type->id)>{{ $type->name }}</option>
@endforeach
</flux:select>
</flux:card>
function normaliseSchema(raw) {
if (typeof raw === 'string') {
try { raw = JSON.parse(raw); } catch (_) { return []; }
}
if (Array.isArray(raw)) return raw;
if (!raw || typeof raw !== 'object') return [];
if (Array.isArray(raw.fields)) return raw.fields;
if (Array.isArray(raw.schema)) return raw.schema;
return Object.values(raw).filter(field => field && typeof field === 'object' && field.key);
}
<flux:card class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">Structured data</h2>
<p class="text-sm text-zinc-500">This phase retains JSON editing while adding validation, copies, placement controls and preview. Schema-generated field controls follow in the next checkpoint.</p>
</div>
<button type="button" id="format-json" class="rounded-lg border border-zinc-300 px-3 py-2 text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800">Format JSON</button>
</div>
<textarea id="data-json" class="min-h-[32rem] w-full rounded-xl border border-zinc-300 bg-white p-4 font-mono text-sm text-zinc-900 shadow-sm focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" name="data_json" spellcheck="false">{{ old('data_json', json_encode($document->data ?: [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) }}</textarea>
<p id="json-message" class="hidden text-sm"></p>
</flux:card>
</div>
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, character => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
})[character]);
}
<aside class="space-y-6">
<flux:card class="space-y-4">
<h2 class="text-lg font-semibold">Publishing</h2>
<flux:select name="status" label="Status">
<option value="draft" @selected(old('status', $document->status ?: 'draft') === 'draft')>Draft</option>
<option value="published" @selected(old('status', $document->status) === 'published')>Published</option>
</flux:select>
<flux:input type="datetime-local" name="published_at" label="Publish at" value="{{ old('published_at', $document->published_at?->format('Y-m-d\TH:i')) }}" />
<flux:input type="datetime-local" name="expires_at" label="Expire at" value="{{ old('expires_at', $document->expires_at?->format('Y-m-d\TH:i')) }}" />
<flux:button type="submit" variant="primary" class="w-full">{{ $document->exists ? 'Save document' : 'Create document' }}</flux:button>
</flux:card>
function get(path) {
return path.split('.').reduce((value, key) => value?.[key], data);
}
@if($document->exists)
<flux:card>
<h2 class="text-lg font-semibold">Usage</h2>
<p class="mt-1 text-sm text-zinc-500">{{ $document->placements->count() }} placement{{ $document->placements->count() === 1 ? '' : 's' }}</p>
<div class="mt-4 space-y-2">
@forelse($document->placements as $placement)
<a class="block rounded-lg border border-zinc-200 p-3 text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800" href="{{ route('content.pages.edit', $placement->page_key) }}">
<strong>{{ ucfirst($placement->page_key ?: 'Page') }}</strong>
<span class="block text-xs uppercase text-zinc-500">{{ $placement->region }}</span>
</a>
@empty
<p class="text-sm text-zinc-500">Not currently assigned to a page.</p>
@endforelse
</div>
</flux:card>
@endif
</aside>
</form>
</div>
function set(path, value) {
const parts = path.split('.');
let target = data;
parts.slice(0, -1).forEach(key => {
if (target[key] === undefined || target[key] === null) {
target[key] = /^\d+$/.test(parts[parts.indexOf(key) + 1] ?? '') ? [] : {};
}
target = target[key];
});
target[parts.at(-1)] = value;
sync();
}
@push('scripts')
<script>
document.getElementById('format-json')?.addEventListener('click', () => {
const field = document.getElementById('data-json');
const message = document.getElementById('json-message');
try {
field.value = JSON.stringify(JSON.parse(field.value), null, 2);
message.textContent = 'Valid JSON.';
message.className = 'text-sm text-green-700 dark:text-green-300';
} catch (error) {
message.textContent = error.message;
message.className = 'text-sm text-red-700 dark:text-red-300';
}
});
</script>
@endpush
function sync() {
dataField.value = JSON.stringify(data, null, 2);
}
function defaultValue(field) {
if (Object.prototype.hasOwnProperty.call(field, 'default')) return field.default;
if (field.type === 'boolean') return false;
if (field.type === 'repeater') return [];
return '';
}
function fieldMarkup(field, value, path) {
const help = field.help
? `<p class="mt-1 text-xs text-zinc-500">${escapeHtml(field.help)}</p>`
: '';
if (field.type === 'boolean') {
return `
<label class="flex items-center justify-between gap-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<span>
<strong class="block text-sm">${escapeHtml(field.label)}</strong>
${help}
</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input class="peer sr-only" data-path="${escapeHtml(path)}" type="checkbox" ${value === true ? 'checked' : ''}>
<span class="absolute inset-0 rounded-full bg-zinc-300 transition peer-checked:bg-blue-600"></span>
<span class="absolute left-1 h-4 w-4 rounded-full bg-white transition peer-checked:translate-x-5"></span>
</span>
</label>`;
}
if (field.type === 'textarea' || field.type === 'richtext') {
return `
<label class="block text-sm font-medium">
${escapeHtml(field.label)}
<textarea data-path="${escapeHtml(path)}" class="mt-2 min-h-28 w-full rounded-lg border border-zinc-300 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-950">${escapeHtml(value)}</textarea>
</label>${help}`;
}
if (field.type === 'repeater') {
return repeaterMarkup(field, Array.isArray(value) ? value : [], path) + help;
}
const type = field.type === 'number' ? 'number' : field.type === 'email' ? 'email' : 'text';
return `
<label class="block text-sm font-medium">
${escapeHtml(field.label)}
<input
data-path="${escapeHtml(path)}"
type="${type}"
value="${escapeHtml(value)}"
${field.min !== undefined ? `min="${escapeHtml(field.min)}"` : ''}
${field.max !== undefined ? `max="${escapeHtml(field.max)}"` : ''}
class="mt-2 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 dark:border-zinc-700 dark:bg-zinc-950"
>
</label>${help}`;
}
function repeaterMarkup(field, items, path) {
return `
<section class="rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<h3 class="font-semibold">${escapeHtml(field.label)}</h3>
<p class="text-xs text-zinc-500">Items can be reordered, hidden, or removed.</p>
</div>
<button type="button" data-add="${escapeHtml(path)}" class="rounded-lg border px-3 py-1.5 text-sm">Add item</button>
</div>
<div class="space-y-3">
${items.length ? items.map((item, index) => `
<article class="rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div class="mb-3 flex items-center justify-between gap-3">
<strong class="text-sm">Item ${index + 1}</strong>
<div class="flex gap-2">
<button type="button" data-up="${escapeHtml(path)}.${index}" class="rounded border px-2 py-1" title="Move up">↑</button>
<button type="button" data-down="${escapeHtml(path)}.${index}" class="rounded border px-2 py-1" title="Move down">↓</button>
<button type="button" data-remove="${escapeHtml(path)}.${index}" class="rounded border border-red-300 px-2 py-1 text-red-700">Remove</button>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
${(field.fields || []).map(child => `
<div class="${child.type === 'textarea' || child.type === 'richtext' ? 'md:col-span-2' : ''}">
${fieldMarkup(child, item?.[child.key] ?? defaultValue(child), `${path}.${index}.${child.key}`)}
</div>`).join('')}
</div>
</article>`).join('') : '<p class="rounded-lg border border-dashed p-6 text-center text-sm text-zinc-500">No items. Select “Add item” to create one.</p>'}
</div>
</section>`;
}
function render() {
const schema = normaliseSchema(rawSchemas[String(typeSelect.value)]);
status.textContent = schema.length ? `${schema.length} editable field${schema.length === 1 ? '' : 's'}` : 'Schema unavailable';
if (!schema.length) {
host.innerHTML = `
<div class="rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-800">
This content type has no usable visual schema. Run <code>php artisan content:install-saasland</code> once to refresh schemas without replacing content.
</div>`;
return;
}
host.innerHTML = schema.map(field => fieldMarkup(
field,
Object.prototype.hasOwnProperty.call(data, field.key) ? data[field.key] : defaultValue(field),
field.key
)).join('');
bind();
}
function bind() {
host.querySelectorAll('[data-path]').forEach(element => {
const eventName = element.type === 'checkbox' ? 'change' : 'input';
element.addEventListener(eventName, () => {
let value = element.type === 'checkbox' ? element.checked : element.value;
if (element.type === 'number') value = element.value === '' ? null : Number(element.value);
set(element.dataset.path, value);
});
});
host.querySelectorAll('[data-add]').forEach(button => {
button.addEventListener('click', () => {
const schema = normaliseSchema(rawSchemas[String(typeSelect.value)]);
const field = schema.find(candidate => candidate.key === button.dataset.add);
const items = Array.isArray(get(button.dataset.add)) ? get(button.dataset.add) : [];
const newItem = {};
(field?.fields || []).forEach(child => newItem[child.key] = defaultValue(child));
items.push(newItem);
set(button.dataset.add, items);
render();
});
});
host.querySelectorAll('[data-remove]').forEach(button => {
button.addEventListener('click', () => {
if (!confirm('Remove this item?')) return;
const parts = button.dataset.remove.split('.');
const index = Number(parts.pop());
const path = parts.join('.');
const items = Array.isArray(get(path)) ? get(path) : [];
items.splice(index, 1);
set(path, items);
render();
});
});
host.querySelectorAll('[data-up], [data-down]').forEach(button => {
button.addEventListener('click', () => {
const raw = button.dataset.up ?? button.dataset.down;
const parts = raw.split('.');
const index = Number(parts.pop());
const path = parts.join('.');
const items = Array.isArray(get(path)) ? get(path) : [];
const destination = button.dataset.up !== undefined ? index - 1 : index + 1;
if (destination < 0 || destination >= items.length) return;
[items[index], items[destination]] = [items[destination], items[index]];
set(path, items);
render();
});
});
}
typeSelect.addEventListener('change', () => {
const schema = normaliseSchema(rawSchemas[String(typeSelect.value)]);
const nextData = {};
schema.forEach(field => nextData[field.key] = defaultValue(field));
data = nextData;
sync();
render();
});
dataField.addEventListener('change', () => {
try {
data = parseObject(dataField.value);
render();
} catch (error) {
alert(`Invalid JSON: ${error.message}`);
}
});
form.addEventListener('submit', sync);
render();
});
</script>
</x-layouts.app>
+399
View File
@@ -0,0 +1,399 @@
<x-layouts.app :title="$document->exists ? $document->title : __('Create content document')">
<div class="mx-auto max-w-7xl space-y-6" id="content-editor">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<a href="{{ route('content.index') }}" class="text-sm text-zinc-500 hover:underline">
Content documents
</a>
<h1 class="mt-1 text-2xl font-semibold">
{{ $document->exists ? $document->title : 'Create content document' }}
</h1>
<p class="mt-1 text-sm text-zinc-500">
Edit every frontend field below. JSON is retained only as an emergency advanced tool.
</p>
</div>
@if ($document->exists)
<div class="flex flex-wrap gap-2">
<form method="POST" action="{{ route('content.documents.duplicate', $document) }}">
@csrf
<flux:button type="submit" size="sm">Create independent copy</flux:button>
</form>
<form
method="POST"
action="{{ route('content.documents.destroy', $document) }}"
onsubmit="return confirm('Delete this unplaced document permanently?')"
>
@csrf
@method('DELETE')
<flux:button type="submit" size="sm" variant="danger">Delete</flux:button>
</form>
</div>
@endif
</div>
@if (session('status'))
<div class="rounded-xl border border-green-300 bg-green-50 p-4 text-sm text-green-800">
{{ session('status') }}
</div>
@endif
@if ($errors->any())
<div class="rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-800">
<ul class="list-disc pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
@if ($document->exists && $document->placements->count() > 1)
<div class="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900">
<strong>Shared document.</strong>
Changes affect all {{ $document->placements->count() }} placements.
</div>
@endif
<form
method="POST"
action="{{ $document->exists ? route('content.documents.update', $document) : route('content.documents.store') }}"
class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_21rem]"
id="document-form"
>
@csrf
@if ($document->exists)
@method('PUT')
@endif
<div class="space-y-6">
<flux:card class="space-y-5">
<h2 class="text-lg font-semibold">Document</h2>
<flux:input name="title" label="Internal title" value="{{ old('title', $document->title) }}" required />
<flux:input name="slug" label="Slug" value="{{ old('slug', $document->slug) }}" />
<flux:select name="content_type_id" label="Content type" id="content-type-select" required>
@foreach ($types as $type)
<option
value="{{ $type->id }}"
@selected((int) old('content_type_id', $selectedType?->id) === $type->id)
>
{{ $type->name }}
</option>
@endforeach
</flux:select>
</flux:card>
<flux:card class="space-y-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold">Frontend content</h2>
<p class="text-sm text-zinc-500">
Switches take effect when this document is saved.
</p>
</div>
<span id="schema-status" class="rounded-full bg-zinc-100 px-3 py-1 text-xs text-zinc-600"></span>
</div>
<div id="schema-fields" class="space-y-5"></div>
<details class="rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<summary class="cursor-pointer text-sm font-medium">Advanced JSON (emergency use only)</summary>
<p class="mt-2 text-xs text-zinc-500">
Normal editing should be performed with the controls above.
</p>
<textarea
id="data-json"
class="mt-4 min-h-80 w-full rounded-xl border border-zinc-300 bg-white p-4 font-mono text-sm dark:border-zinc-700 dark:bg-zinc-950"
name="data_json"
spellcheck="false"
>{{ old('data_json', json_encode($document->data ?: [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) }}</textarea>
</details>
</flux:card>
</div>
<aside class="space-y-6">
<flux:card class="space-y-4">
<h2 class="text-lg font-semibold">Publishing</h2>
<flux:select name="status" label="Status">
<option value="draft" @selected(old('status', $document->status ?: 'draft') === 'draft')>Draft</option>
<option value="published" @selected(old('status', $document->status) === 'published')>Published</option>
</flux:select>
<flux:input
type="datetime-local"
name="published_at"
label="Publish at"
value="{{ old('published_at', $document->published_at?->format('Y-m-d\TH:i')) }}"
/>
<flux:input
type="datetime-local"
name="expires_at"
label="Expire at"
value="{{ old('expires_at', $document->expires_at?->format('Y-m-d\TH:i')) }}"
/>
<flux:button type="submit" variant="primary" class="w-full">Save document</flux:button>
</flux:card>
@if ($document->exists)
<flux:card>
<h2 class="text-lg font-semibold">Usage</h2>
<div class="mt-3 space-y-2">
@forelse ($document->placements as $placement)
<a
class="block rounded-lg border p-3 text-sm"
href="{{ route('content.pages.edit', $placement->page_key) }}"
>
<strong>{{ ucfirst($placement->page_key) }}</strong>
<span class="block text-xs uppercase text-zinc-500">{{ $placement->region }}</span>
</a>
@empty
<p class="text-sm text-zinc-500">Not placed on a page.</p>
@endforelse
</div>
</flux:card>
@endif
</aside>
</form>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const rawSchemas = @json($types->mapWithKeys(fn ($type) => [(string) $type->id => $type->schema ?? []]));
const dataField = document.getElementById('data-json');
const host = document.getElementById('schema-fields');
const typeSelect = document.getElementById('content-type-select');
const status = document.getElementById('schema-status');
const form = document.getElementById('document-form');
let data = parseObject(dataField.value);
function parseObject(value) {
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
try {
const parsed = JSON.parse(value || '{}');
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch (_) {
return {};
}
}
function normaliseSchema(raw) {
if (typeof raw === 'string') {
try { raw = JSON.parse(raw); } catch (_) { return []; }
}
if (Array.isArray(raw)) return raw;
if (!raw || typeof raw !== 'object') return [];
if (Array.isArray(raw.fields)) return raw.fields;
if (Array.isArray(raw.schema)) return raw.schema;
return Object.values(raw).filter(field => field && typeof field === 'object' && field.key);
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, character => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
})[character]);
}
function get(path) {
return path.split('.').reduce((value, key) => value?.[key], data);
}
function set(path, value) {
const parts = path.split('.');
let target = data;
parts.slice(0, -1).forEach(key => {
if (target[key] === undefined || target[key] === null) {
target[key] = /^\d+$/.test(parts[parts.indexOf(key) + 1] ?? '') ? [] : {};
}
target = target[key];
});
target[parts.at(-1)] = value;
sync();
}
function sync() {
dataField.value = JSON.stringify(data, null, 2);
}
function defaultValue(field) {
if (Object.prototype.hasOwnProperty.call(field, 'default')) return field.default;
if (field.type === 'boolean') return false;
if (field.type === 'repeater') return [];
return '';
}
function fieldMarkup(field, value, path) {
const help = field.help
? `<p class="mt-1 text-xs text-zinc-500">${escapeHtml(field.help)}</p>`
: '';
if (field.type === 'boolean') {
return `
<label class="flex items-center justify-between gap-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<span>
<strong class="block text-sm">${escapeHtml(field.label)}</strong>
${help}
</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input class="peer sr-only" data-path="${escapeHtml(path)}" type="checkbox" ${value === true ? 'checked' : ''}>
<span class="absolute inset-0 rounded-full bg-zinc-300 transition peer-checked:bg-blue-600"></span>
<span class="absolute left-1 h-4 w-4 rounded-full bg-white transition peer-checked:translate-x-5"></span>
</span>
</label>`;
}
if (field.type === 'textarea' || field.type === 'richtext') {
return `
<label class="block text-sm font-medium">
${escapeHtml(field.label)}
<textarea data-path="${escapeHtml(path)}" class="mt-2 min-h-28 w-full rounded-lg border border-zinc-300 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-950">${escapeHtml(value)}</textarea>
</label>${help}`;
}
if (field.type === 'repeater') {
return repeaterMarkup(field, Array.isArray(value) ? value : [], path) + help;
}
const type = field.type === 'number' ? 'number' : field.type === 'email' ? 'email' : 'text';
return `
<label class="block text-sm font-medium">
${escapeHtml(field.label)}
<input
data-path="${escapeHtml(path)}"
type="${type}"
value="${escapeHtml(value)}"
${field.min !== undefined ? `min="${escapeHtml(field.min)}"` : ''}
${field.max !== undefined ? `max="${escapeHtml(field.max)}"` : ''}
class="mt-2 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 dark:border-zinc-700 dark:bg-zinc-950"
>
</label>${help}`;
}
function repeaterMarkup(field, items, path) {
return `
<section class="rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<h3 class="font-semibold">${escapeHtml(field.label)}</h3>
<p class="text-xs text-zinc-500">Items can be reordered, hidden, or removed.</p>
</div>
<button type="button" data-add="${escapeHtml(path)}" class="rounded-lg border px-3 py-1.5 text-sm">Add item</button>
</div>
<div class="space-y-3">
${items.length ? items.map((item, index) => `
<article class="rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div class="mb-3 flex items-center justify-between gap-3">
<strong class="text-sm">Item ${index + 1}</strong>
<div class="flex gap-2">
<button type="button" data-up="${escapeHtml(path)}.${index}" class="rounded border px-2 py-1" title="Move up">↑</button>
<button type="button" data-down="${escapeHtml(path)}.${index}" class="rounded border px-2 py-1" title="Move down">↓</button>
<button type="button" data-remove="${escapeHtml(path)}.${index}" class="rounded border border-red-300 px-2 py-1 text-red-700">Remove</button>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
${(field.fields || []).map(child => `
<div class="${child.type === 'textarea' || child.type === 'richtext' ? 'md:col-span-2' : ''}">
${fieldMarkup(child, item?.[child.key] ?? defaultValue(child), `${path}.${index}.${child.key}`)}
</div>`).join('')}
</div>
</article>`).join('') : '<p class="rounded-lg border border-dashed p-6 text-center text-sm text-zinc-500">No items. Select “Add item” to create one.</p>'}
</div>
</section>`;
}
function render() {
const schema = normaliseSchema(rawSchemas[String(typeSelect.value)]);
status.textContent = schema.length ? `${schema.length} editable field${schema.length === 1 ? '' : 's'}` : 'Schema unavailable';
if (!schema.length) {
host.innerHTML = `
<div class="rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-800">
This content type has no usable visual schema. Run <code>php artisan content:install-saasland</code> once to refresh schemas without replacing content.
</div>`;
return;
}
host.innerHTML = schema.map(field => fieldMarkup(
field,
Object.prototype.hasOwnProperty.call(data, field.key) ? data[field.key] : defaultValue(field),
field.key
)).join('');
bind();
}
function bind() {
host.querySelectorAll('[data-path]').forEach(element => {
const eventName = element.type === 'checkbox' ? 'change' : 'input';
element.addEventListener(eventName, () => {
let value = element.type === 'checkbox' ? element.checked : element.value;
if (element.type === 'number') value = element.value === '' ? null : Number(element.value);
set(element.dataset.path, value);
});
});
host.querySelectorAll('[data-add]').forEach(button => {
button.addEventListener('click', () => {
const schema = normaliseSchema(rawSchemas[String(typeSelect.value)]);
const field = schema.find(candidate => candidate.key === button.dataset.add);
const items = Array.isArray(get(button.dataset.add)) ? get(button.dataset.add) : [];
const newItem = {};
(field?.fields || []).forEach(child => newItem[child.key] = defaultValue(child));
items.push(newItem);
set(button.dataset.add, items);
render();
});
});
host.querySelectorAll('[data-remove]').forEach(button => {
button.addEventListener('click', () => {
if (!confirm('Remove this item?')) return;
const parts = button.dataset.remove.split('.');
const index = Number(parts.pop());
const path = parts.join('.');
const items = Array.isArray(get(path)) ? get(path) : [];
items.splice(index, 1);
set(path, items);
render();
});
});
host.querySelectorAll('[data-up], [data-down]').forEach(button => {
button.addEventListener('click', () => {
const raw = button.dataset.up ?? button.dataset.down;
const parts = raw.split('.');
const index = Number(parts.pop());
const path = parts.join('.');
const items = Array.isArray(get(path)) ? get(path) : [];
const destination = button.dataset.up !== undefined ? index - 1 : index + 1;
if (destination < 0 || destination >= items.length) return;
[items[index], items[destination]] = [items[destination], items[index]];
set(path, items);
render();
});
});
}
typeSelect.addEventListener('change', () => {
const schema = normaliseSchema(rawSchemas[String(typeSelect.value)]);
const nextData = {};
schema.forEach(field => nextData[field.key] = defaultValue(field));
data = nextData;
sync();
render();
});
dataField.addEventListener('change', () => {
try {
data = parseObject(dataField.value);
render();
} catch (error) {
alert(`Invalid JSON: ${error.message}`);
}
});
form.addEventListener('submit', sync);
render();
});
</script>
</x-layouts.app>
@@ -0,0 +1,34 @@
@if ((bool) ($data['enabled'] ?? true))
@php
$includeIds = collect(explode(',', (string) ($data['include_ids'] ?? '')))->map(static fn ($id) => (int) trim($id))->filter();
$excludeIds = collect(explode(',', (string) ($data['exclude_ids'] ?? '')))->map(static fn ($id) => (int) trim($id))->filter();
$items = collect(${latestBlogPosts} ?? [])
->when($includeIds->isNotEmpty(), static fn ($collection) => $collection->filter(static fn ($item) => $includeIds->contains((int) $item->id)))
->reject(static fn ($item) => $excludeIds->contains((int) $item->id))
->take(max(1, (int) ($data['limit'] ?? 3)));
@endphp
@if ($items->isNotEmpty())
<section class="section">
<div class="wrap">
<div class="heading">
@if (filled($data['eyebrow'] ?? null))<span>{{ $data['eyebrow'] }}</span>@endif
@if (filled($data['heading'] ?? null))<h2>{{ $data['heading'] }}</h2>@endif
@if (filled($data['description'] ?? null))<p>{{ $data['description'] }}</p>@endif
</div>
<div class="grid three">
@foreach ($items as $post)
<article class="card">
@if ($post->published_at)<b>{{ optional($post->published_at)->format('d M Y') }}</b>@endif
<h3>{{ $post->title }}</h3>
@if (filled($post->excerpt))<p>{{ $post->excerpt }}</p>@endif
@if (Route::has('blog.public.show'))
<a class="read-more" href="{{ route('blog.public.show', $post->slug) }}">Read article →</a>
@endif
</article>
@endforeach
</div>
</div>
</section>
@endif
@endif
@@ -0,0 +1,11 @@
@if ((bool) ($data['enabled'] ?? true))
<section class="cta" @if (filled($data['anchor'] ?? null)) id="{{ $data['anchor'] }}" @endif>
<div class="wrap">
@if (filled($data['heading'] ?? null))<h2>{{ $data['heading'] }}</h2>@endif
@if (filled($data['description'] ?? null))<p>{{ $data['description'] }}</p>@endif
@if (filled($data['button_label'] ?? null))
<a class="btn light" href="{{ $data['button_url'] ?? '#' }}">{{ $data['button_label'] }} →</a>
@endif
</div>
</section>
@endif
@@ -0,0 +1,32 @@
@if ((bool) ($data['enabled'] ?? true))
@php
$includeIds = collect(explode(',', (string) ($data['include_ids'] ?? '')))->map(static fn ($id) => (int) trim($id))->filter();
$excludeIds = collect(explode(',', (string) ($data['exclude_ids'] ?? '')))->map(static fn ($id) => (int) trim($id))->filter();
$items = collect($faqs ?? [])
->when($includeIds->isNotEmpty(), static fn ($collection) => $collection->filter(static fn ($item) => $includeIds->contains((int) $item->id)))
->reject(static fn ($item) => $excludeIds->contains((int) $item->id))
->take(max(1, (int) ($data['limit'] ?? 12)));
@endphp
@if ($items->isNotEmpty())
<section class="section">
<div class="wrap faq">
<div>
@if (filled($data['eyebrow'] ?? null))<span class="section-label">{{ $data['eyebrow'] }}</span>@endif
@if (filled($data['heading'] ?? null))<h2>{{ $data['heading'] }}</h2>@endif
@if (filled($data['description'] ?? null))<p>{{ $data['description'] }}</p>@endif
</div>
<div>
@foreach ($items as $faq)
<article>
<button type="button" @click="openFaq = openFaq === {{ (int) $faq->id }} ? null : {{ (int) $faq->id }}">
{{ $faq->question }}<span>+</span>
</button>
<p x-cloak x-show="openFaq === {{ (int) $faq->id }}">{{ $faq->answer }}</p>
</article>
@endforeach
</div>
</div>
</section>
@endif
@endif
@@ -0,0 +1,26 @@
@if ((bool) ($data['enabled'] ?? true))
@php
$items = collect($data['items'] ?? [])->filter(static fn ($item) => (bool) ($item['enabled'] ?? true));
@endphp
@if ($items->isNotEmpty())
<section class="section" id="features">
<div class="wrap">
<div class="heading">
@if (filled($data['eyebrow'] ?? null))<span>{{ $data['eyebrow'] }}</span>@endif
@if (filled($data['heading'] ?? null))<h2>{{ $data['heading'] }}</h2>@endif
@if (filled($data['description'] ?? null))<p>{{ $data['description'] }}</p>@endif
</div>
<div class="grid three">
@foreach ($items as $item)
<article class="card">
<b>{{ str_pad((string) $loop->iteration, 2, '0', STR_PAD_LEFT) }}</b>
<h3>{{ $item['title'] ?? '' }}</h3>
@if (filled($item['description'] ?? null))<p>{{ $item['description'] }}</p>@endif
</article>
@endforeach
</div>
</div>
</section>
@endif
@endif
@@ -0,0 +1,46 @@
@if ((bool) ($data['enabled'] ?? true))
@php
$metrics = collect($data['metrics'] ?? [])->filter(static fn ($metric) => (bool) ($metric['enabled'] ?? true));
@endphp
<section class="hero">
<div class="glow one"></div>
<div class="glow two"></div>
<div class="wrap hero-inner">
@if (filled($data['eyebrow'] ?? null))<span class="eyebrow">{{ $data['eyebrow'] }}</span>@endif
<h1>{{ $data['heading'] ?? $document->title }}</h1>
@if (filled($data['description'] ?? null))<p>{{ $data['description'] }}</p>@endif
@if (filled($data['primary_label'] ?? null) || filled($data['secondary_label'] ?? null))
<div class="actions">
@if (filled($data['primary_label'] ?? null))
<a class="btn primary" href="{{ $data['primary_url'] ?? '#' }}">{{ $data['primary_label'] }} →</a>
@endif
@if (filled($data['secondary_label'] ?? null))
<a class="btn secondary" href="{{ $data['secondary_url'] ?? '#' }}">{{ $data['secondary_label'] }}</a>
@endif
</div>
@endif
@if ((bool) ($data['show_metrics'] ?? false) && $metrics->isNotEmpty())
<div class="dashboard">
<div class="dash-top"><b>Performance overview</b><span>Live</span></div>
<div class="metrics">
@foreach ($metrics as $metric)
<article>
<small>{{ $metric['label'] ?? '' }}</small>
<strong>{{ $metric['value'] ?? '' }}</strong>
<em>{{ $metric['change'] ?? '' }}</em>
</article>
@endforeach
</div>
<div class="chart">
@foreach ($metrics as $metric)
<i style="height: {{ max(0, min(100, (int) ($metric['height'] ?? 50))) }}%"></i>
@endforeach
</div>
</div>
@endif
</div>
</section>
@endif
@@ -0,0 +1,34 @@
@if ((bool) ($data['enabled'] ?? true))
@php
$includeIds = collect(explode(',', (string) ($data['include_ids'] ?? '')))->map(static fn ($id) => (int) trim($id))->filter();
$excludeIds = collect(explode(',', (string) ($data['exclude_ids'] ?? '')))->map(static fn ($id) => (int) trim($id))->filter();
$items = collect(${latestNewsArticles} ?? [])
->when($includeIds->isNotEmpty(), static fn ($collection) => $collection->filter(static fn ($item) => $includeIds->contains((int) $item->id)))
->reject(static fn ($item) => $excludeIds->contains((int) $item->id))
->take(max(1, (int) ($data['limit'] ?? 3)));
@endphp
@if ($items->isNotEmpty())
<section class="section">
<div class="wrap">
<div class="heading">
@if (filled($data['eyebrow'] ?? null))<span>{{ $data['eyebrow'] }}</span>@endif
@if (filled($data['heading'] ?? null))<h2>{{ $data['heading'] }}</h2>@endif
@if (filled($data['description'] ?? null))<p>{{ $data['description'] }}</p>@endif
</div>
<div class="grid three">
@foreach ($items as $article)
<article class="card">
@if ($article->published_at)<b>{{ optional($article->published_at)->format('d M Y') }}</b>@endif
<h3>{{ $article->title }}</h3>
@if (filled($article->excerpt))<p>{{ $article->excerpt }}</p>@endif
@if (Route::has('news.public.show'))
<a class="read-more" href="{{ route('news.public.show', $article->slug) }}">Read update →</a>
@endif
</article>
@endforeach
</div>
</div>
</section>
@endif
@endif
@@ -0,0 +1,20 @@
@if ((bool) ($data['enabled'] ?? true))
<section class="section alt">
<div class="wrap">
<div class="content-card newsletter-panel">
<div>
@if (filled($data['eyebrow'] ?? null))<span class="section-label">{{ $data['eyebrow'] }}</span>@endif
@if (filled($data['heading'] ?? null))<h2>{{ $data['heading'] }}</h2>@endif
@if (filled($data['description'] ?? null))<p>{{ $data['description'] }}</p>@endif
</div>
@if (Route::has('newsletter.public.subscribe'))
<form method="POST" action="{{ route('newsletter.public.subscribe') }}">
@csrf
<input type="email" name="email" required placeholder="{{ $data['placeholder'] ?? 'you@example.com' }}" aria-label="Email address">
<button class="btn primary" type="submit">{{ $data['button_label'] ?? 'Subscribe' }}</button>
</form>
@endif
</div>
</div>
</section>
@endif
@@ -0,0 +1,53 @@
@if ((bool) ($data['enabled'] ?? true))
@php
$includeIds = collect(explode(',', (string) ($data['include_ids'] ?? '')))
->map(static fn ($id) => (int) trim($id))
->filter()
->values();
$excludeIds = collect(explode(',', (string) ($data['exclude_ids'] ?? '')))
->map(static fn ($id) => (int) trim($id))
->filter()
->values();
$items = collect($pricingPlans ?? [])
->when($includeIds->isNotEmpty(), static fn ($collection) => $collection->filter(
static fn ($item) => $includeIds->contains((int) $item->id)
))
->reject(static fn ($item) => $excludeIds->contains((int) $item->id))
->take(max(1, (int) ($data['limit'] ?? 6)));
@endphp
@if ($items->isNotEmpty())
<section class="section alt" id="pricing">
<div class="wrap">
<div class="heading">
@if (filled($data['eyebrow'] ?? null))<span>{{ $data['eyebrow'] }}</span>@endif
@if (filled($data['heading'] ?? null))<h2>{{ $data['heading'] }}</h2>@endif
@if (filled($data['description'] ?? null))<p>{{ $data['description'] }}</p>@endif
</div>
<div class="grid three pricing">
@foreach ($items as $plan)
<article class="card plan {{ $plan->is_featured ? 'featured' : '' }}">
@if ($plan->is_featured)<div class="popular">Most popular</div>@endif
<h3>{{ $plan->name }}</h3>
<strong>
@if ($plan->price !== null)
{{ $plan->currency === 'GBP' ? '£' : $plan->currency.' ' }}{{ rtrim(rtrim(number_format((float) $plan->price, 2), '0'), '.') }}
<small>/{{ $plan->billing_period ?: 'month' }}</small>
@else
Custom
@endif
</strong>
@if (filled($plan->description))<p>{{ $plan->description }}</p>@endif
@if (! empty($plan->features))
<ul>@foreach ((array) $plan->features as $feature)<li>{{ $feature }}</li>@endforeach</ul>
@endif
<a class="btn {{ $plan->is_featured ? 'primary' : 'secondary' }}" href="{{ $plan->cta_url ?: '#contact' }}">
{{ $plan->cta_label ?: 'Learn more' }}
</a>
</article>
@endforeach
</div>
</div>
</section>
@endif
@endif
@@ -0,0 +1,3 @@
@if ((bool) ($data['enabled'] ?? true))
@include('stylist-theme-saasland::partials.footer', ['footerContent' => $data])
@endif
@@ -0,0 +1,3 @@
@if ((bool) ($data['enabled'] ?? true))
@include('stylist-theme-saasland::partials.header', ['headerContent' => $data])
@endif
@@ -0,0 +1,31 @@
@if ((bool) ($data['enabled'] ?? true))
@php
$includeIds = collect(explode(',', (string) ($data['include_ids'] ?? '')))->map(static fn ($id) => (int) trim($id))->filter();
$excludeIds = collect(explode(',', (string) ($data['exclude_ids'] ?? '')))->map(static fn ($id) => (int) trim($id))->filter();
$items = collect($teamMembers ?? [])
->when($includeIds->isNotEmpty(), static fn ($collection) => $collection->filter(static fn ($item) => $includeIds->contains((int) $item->id)))
->reject(static fn ($item) => $excludeIds->contains((int) $item->id))
->take(max(1, (int) ($data['limit'] ?? 8)));
@endphp
@if ($items->isNotEmpty())
<section class="section alt">
<div class="wrap">
<div class="heading">
@if (filled($data['eyebrow'] ?? null))<span>{{ $data['eyebrow'] }}</span>@endif
@if (filled($data['heading'] ?? null))<h2>{{ $data['heading'] }}</h2>@endif
@if (filled($data['description'] ?? null))<p>{{ $data['description'] }}</p>@endif
</div>
<div class="grid three">
@foreach ($items as $member)
<article class="card">
<h3>{{ $member->name }}</h3>
@if (filled($member->job_title))<b>{{ $member->job_title }}</b>@endif
@if (filled($member->biography))<p>{{ $member->biography }}</p>@endif
</article>
@endforeach
</div>
</div>
</section>
@endif
@endif
@@ -0,0 +1,30 @@
@if ((bool) ($data['enabled'] ?? true))
@php
$includeIds = collect(explode(',', (string) ($data['include_ids'] ?? '')))->map(static fn ($id) => (int) trim($id))->filter();
$excludeIds = collect(explode(',', (string) ($data['exclude_ids'] ?? '')))->map(static fn ($id) => (int) trim($id))->filter();
$items = collect($testimonials ?? [])
->when($includeIds->isNotEmpty(), static fn ($collection) => $collection->filter(static fn ($item) => $includeIds->contains((int) $item->id)))
->reject(static fn ($item) => $excludeIds->contains((int) $item->id))
->take(max(1, (int) ($data['limit'] ?? 6)));
@endphp
@if ($items->isNotEmpty())
<section class="section">
<div class="wrap">
<div class="heading">
@if (filled($data['eyebrow'] ?? null))<span>{{ $data['eyebrow'] }}</span>@endif
@if (filled($data['heading'] ?? null))<h2>{{ $data['heading'] }}</h2>@endif
@if (filled($data['description'] ?? null))<p>{{ $data['description'] }}</p>@endif
</div>
<div class="grid three">
@foreach ($items as $testimonial)
<blockquote class="card">
“{{ $testimonial->content }}”
<footer>{{ $testimonial->name }}@if ($testimonial->company) · {{ $testimonial->company }}@endif</footer>
</blockquote>
@endforeach
</div>
</div>
</section>
@endif
@endif
@@ -0,0 +1,37 @@
@php($footerContent = $footerContent ?? [])
<footer class="footer">
<div class="wrap footer-grid">
<div class="footer-brand">
<a class="logo" href="{{ url('/') }}">{{ $siteTitle }}</a>
<p>{{ $footerContent['description'] ?? $siteDescription }}</p>
</div>
@if($footerContent['show_navigation'] ?? true)<div>
<b>Explore</b>
@foreach ($footerNavigation as $item)
<a href="{{ $item['url'] }}">{{ $item['label'] }}</a>
@endforeach
</div>@endif
@if($footerContent['show_account_links'] ?? true)<div>
<b>Account</b>
@auth
<a href="{{ route('dashboard') }}">Dashboard</a>
@if (Route::has('profile.edit'))<a href="{{ route('profile.edit') }}">Profile</a>@endif
@else
@if (Route::has('login'))<a href="{{ route('login') }}">Log in</a>@endif
@if (Route::has('register'))<a href="{{ route('register') }}">Register</a>@endif
@endauth
</div>@endif
@if($footerContent['show_social_links'] ?? true)<div>
<b>Connect</b>
@if ($facebookUrl)<a href="{{ $facebookUrl }}" target="_blank" rel="noopener">Facebook</a>@endif
@if ($xUrl)<a href="{{ $xUrl }}" target="_blank" rel="noopener">X</a>@endif
@if ($linkedinUrl)<a href="{{ $linkedinUrl }}" target="_blank" rel="noopener">LinkedIn</a>@endif
@unless($facebookUrl || $xUrl || $linkedinUrl)<a href="mailto:{{ config('mail.from.address') }}">Contact</a>@endunless
</div>@endif
</div>
<div class="wrap copyright">
<span>© {{ date('Y') }} {{ $siteTitle }}. {{ $footerContent['copyright'] ?? 'All rights reserved.' }}</span>
@if($footerContent['show_back_to_top'] ?? true)<a href="#top">Back to top ↑</a>@endif
</div>
</footer>
@@ -0,0 +1,69 @@
@php($headerContent = $headerContent ?? [])
<header class="nav">
<a href="{{ url('/') }}" class="logo" aria-label="{{ $siteTitle }} home">
@if ($logoUrl)
<img src="{{ $logoUrl }}" alt="{{ $siteTitle }}">
@else
{{ $siteTitle }}
@endif
</a>
@if($headerContent['show_navigation'] ?? true)<nav class="desktop-nav" aria-label="Primary navigation">
@include('stylist-theme-saasland::partials.navigation-items', ['items' => $primaryNavigation])
</nav>@endif
<div class="nav-actions">
@auth
<a class="nav-account" href="{{ route('dashboard') }}">Dashboard</a>
<div class="account-menu" x-data="{ accountOpen: false }">
<button type="button" class="account-trigger" @click="accountOpen = ! accountOpen" :aria-expanded="accountOpen">
<span>{{ str(auth()->user()->name)->substr(0, 1)->upper() }}</span>
<span class="account-name">{{ auth()->user()->name }}</span>
</button>
<div class="account-dropdown" x-show="accountOpen" @click.outside="accountOpen = false" x-cloak>
@if (Route::has('profile.edit'))
<a href="{{ route('profile.edit') }}">Profile</a>
@endif
@if (Route::has('settings.profile'))
<a href="{{ route('settings.profile') }}">Account settings</a>
@endif
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit">Log out</button>
</form>
</div>
</div>
@else
@if (($headerContent['show_login'] ?? true) && Route::has('login'))
<a class="nav-login" href="{{ route('login') }}">{{ $headerContent['login_label'] ?? 'Log in' }}</a>
@endif
@if (($headerContent['show_registration'] ?? true) && Route::has('register'))
<a class="btn primary nav-register" href="{{ route('register') }}">{{ $headerContent['registration_label'] ?? 'Get started' }}</a>
@endif
@endauth
<button class="menu" type="button" aria-label="Toggle navigation" :aria-expanded="menuOpen" @click="menuOpen = ! menuOpen">
<span></span><span></span><span></span>
</button>
</div>
</header>
<div class="mobile-nav" x-show="menuOpen" x-cloak>
@include('stylist-theme-saasland::partials.navigation-items', ['items' => $primaryNavigation])
<div class="mobile-auth">
@auth
<a href="{{ route('dashboard') }}">Dashboard</a>
@if (Route::has('profile.edit'))
<a href="{{ route('profile.edit') }}">Profile</a>
@endif
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit">Log out</button>
</form>
@else
@if (Route::has('login'))<a href="{{ route('login') }}">Log in</a>@endif
@if (Route::has('register'))<a class="mobile-primary" href="{{ route('register') }}">Get started</a>@endif
@endauth
</div>
</div>