fixed main frontend
This commit is contained in:
@@ -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.'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[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>
|
||||
|
||||
@@ -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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[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>
|
||||
Reference in New Issue
Block a user