diff --git a/Modules/Content/Console/InstallSaaslandContent.php b/Modules/Content/Console/InstallSaaslandContent.php
index 27aa628..fdc2462 100644
--- a/Modules/Content/Console/InstallSaaslandContent.php
+++ b/Modules/Content/Console/InstallSaaslandContent.php
@@ -50,9 +50,13 @@ final class InstallSaaslandContent extends Command
]
);
- if ((int) $document->content_type_id !== (int) $type->id) {
- $document->forceFill(['content_type_id' => $type->id])->save();
- }
+ $currentData = is_array($document->data) ? $document->data : [];
+ $mergedData = $this->mergeMissingDefaults($currentData, $definition['data']);
+
+ $document->forceFill([
+ 'content_type_id' => $type->id,
+ 'data' => $mergedData,
+ ])->save();
[$region, $sort] = $order[$key];
PageContent::query()->updateOrCreate(
['page_key' => 'home', 'region' => $region, 'sort_order' => $sort],
@@ -65,6 +69,30 @@ final class InstallSaaslandContent extends Command
return self::SUCCESS;
}
+ private function mergeMissingDefaults(array $current, array $defaults): array
+ {
+ foreach ($defaults as $key => $defaultValue) {
+ if (! array_key_exists($key, $current)) {
+ $current[$key] = $defaultValue;
+ continue;
+ }
+
+ if (
+ is_array($defaultValue)
+ && is_array($current[$key])
+ && ! array_is_list($defaultValue)
+ && ! array_is_list($current[$key])
+ ) {
+ $current[$key] = $this->mergeMissingDefaults(
+ $current[$key],
+ $defaultValue
+ );
+ }
+ }
+
+ return $current;
+ }
+
private function definitions(): array
{
$field = static fn (string $key, string $label, string $type = 'text', array $extra = []): array => array_merge(compact('key', 'label', 'type'), $extra);
@@ -84,11 +112,45 @@ final class InstallSaaslandContent extends Command
'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]),
+ $field('enabled', 'Show header', 'boolean', ['default' => true]),
+ $field('brand_text', 'Brand text'),
+ $field('brand_url', 'Brand URL', 'url'),
+ $field('logo_url', 'Logo URL', 'url'),
+ $field('show_navigation', 'Show navigation', 'boolean', ['default' => true]),
+ $field('links', 'Navigation links', 'repeater', ['fields' => [
+ $field('enabled', 'Show link', 'boolean', ['default' => true]),
+ $field('label', 'Label'),
+ $field('url', 'URL', 'url'),
+ ]]),
+ $field('show_dashboard', 'Show dashboard link', 'boolean', ['default' => true]),
+ $field('dashboard_label', 'Dashboard label'),
+ $field('show_user_name', 'Show signed-in user', 'boolean', ['default' => true]),
+ $field('show_login', 'Show login', 'boolean', ['default' => true]),
+ $field('login_label', 'Login label'),
+ $field('show_registration', 'Show registration', 'boolean', ['default' => true]),
+ $field('registration_label', 'Registration label'),
+ $field('show_mobile_menu', 'Show mobile menu', '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],
+ 'data' => [
+ 'enabled' => true,
+ 'brand_text' => config('app.name'),
+ 'brand_url' => '/',
+ 'logo_url' => '',
+ 'show_navigation' => true,
+ 'links' => [
+ ['enabled' => true, 'label' => 'Home', 'url' => '/'],
+ ['enabled' => true, 'label' => 'Blog', 'url' => '/blog'],
+ ['enabled' => true, 'label' => 'News', 'url' => '/news'],
+ ],
+ 'show_dashboard' => true,
+ 'dashboard_label' => 'Dashboard',
+ 'show_user_name' => true,
+ 'show_login' => true,
+ 'login_label' => 'Log in',
+ 'show_registration' => true,
+ 'registration_label' => 'Get started',
+ 'show_mobile_menu' => true,
+ ],
],
'hero' => [
'title' => 'Homepage hero',
@@ -140,9 +202,48 @@ final class InstallSaaslandContent extends Command
],
'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.'],
+ 'type' => $type('Site footer', 'site-footer', ['footer'], [
+ $field('enabled', 'Show footer', 'boolean', ['default' => true]),
+ $field('brand_text', 'Brand text'),
+ $field('brand_url', 'Brand URL', 'url'),
+ $field('description', 'Description', 'textarea'),
+ $field('columns', 'Footer columns', 'repeater', ['fields' => [
+ $field('enabled', 'Show column', 'boolean', ['default' => true]),
+ $field('heading', 'Heading'),
+ $field('links', 'Links', 'repeater', ['fields' => [
+ $field('enabled', 'Show link', 'boolean', ['default' => true]),
+ $field('label', 'Label'),
+ $field('url', 'URL', 'url'),
+ ]]),
+ ]]),
+ $field('copyright', 'Copyright text'),
+ $field('show_back_to_top', 'Show back-to-top link', 'boolean', ['default' => true]),
+ $field('back_to_top_label', 'Back-to-top label'),
+ ]),
+ 'data' => [
+ 'enabled' => true,
+ 'brand_text' => config('app.name'),
+ 'brand_url' => '/',
+ 'description' => 'Software and services built for modern organisations.',
+ 'columns' => [
+ ['enabled' => true, 'heading' => 'Explore', 'links' => [
+ ['enabled' => true, 'label' => 'Home', 'url' => '/'],
+ ['enabled' => true, 'label' => 'Blog', 'url' => '/blog'],
+ ['enabled' => true, 'label' => 'News', 'url' => '/news'],
+ ]],
+ ['enabled' => true, 'heading' => 'Account', 'links' => [
+ ['enabled' => true, 'label' => 'Dashboard', 'url' => '/dashboard'],
+ ['enabled' => true, 'label' => 'Profile', 'url' => '/settings/profile'],
+ ]],
+ ['enabled' => true, 'heading' => 'Connect', 'links' => [
+ ['enabled' => true, 'label' => 'Contact', 'url' => '/contact'],
+ ]],
+ ],
+ 'copyright' => '© '.date('Y').' '.config('app.name').'. All rights reserved.',
+ 'show_back_to_top' => true,
+ 'back_to_top_label' => 'Back to top ↑',
+ ],
],
- ];
+];
}
}
diff --git a/Modules/Content/InstallSaaslandContent.php b/Modules/Content/InstallSaaslandContent.php
deleted file mode 100644
index 27aa628..0000000
--- a/Modules/Content/InstallSaaslandContent.php
+++ /dev/null
@@ -1,148 +0,0 @@
- ! 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.'],
- ],
- ];
- }
-}
diff --git a/Modules/Content/Resources/views/admin/form.blade.php b/Modules/Content/Resources/views/admin/form.blade.php
index 47ae90d..283fcfe 100644
--- a/Modules/Content/Resources/views/admin/form.blade.php
+++ b/Modules/Content/Resources/views/admin/form.blade.php
@@ -168,10 +168,16 @@
let data = parseObject(dataField.value);
function parseObject(value) {
- if (value && typeof value === 'object' && !Array.isArray(value)) return 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 : {};
+
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
+ ? parsed
+ : {};
} catch (_) {
return {};
}
@@ -179,49 +185,166 @@
function normaliseSchema(raw) {
if (typeof raw === 'string') {
- try { raw = JSON.parse(raw); } catch (_) { return []; }
+ 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);
+
+ 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 pathParts(path) {
+ return String(path || '')
+ .split('.')
+ .filter(part => part !== '');
}
- 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] ?? '') ? [] : {};
+ function getAtPath(path) {
+ return pathParts(path).reduce((value, part) => {
+ if (value === undefined || value === null) {
+ return undefined;
}
- target = target[key];
- });
- target[parts.at(-1)] = value;
- sync();
+
+ return value[part];
+ }, data);
}
- function sync() {
- dataField.value = JSON.stringify(data, null, 2);
+ function setAtPath(path, value) {
+ const parts = pathParts(path);
+
+ if (! parts.length) {
+ return;
+ }
+
+ let target = data;
+
+ parts.forEach((part, index) => {
+ const isLast = index === parts.length - 1;
+
+ if (isLast) {
+ target[part] = value;
+ return;
+ }
+
+ const nextPart = parts[index + 1];
+ const shouldBeArray = /^\d+$/.test(nextPart);
+
+ if (
+ target[part] === undefined
+ || target[part] === null
+ || typeof target[part] !== 'object'
+ ) {
+ target[part] = shouldBeArray ? [] : {};
+ }
+
+ target = target[part];
+ });
+
+ syncJson();
+ }
+
+ function fieldForPath(schema, path) {
+ const parts = pathParts(path);
+ let fields = schema;
+ let matched = null;
+
+ for (const part of parts) {
+ if (/^\d+$/.test(part)) {
+ continue;
+ }
+
+ matched = (fields || []).find(field => field.key === part) ?? null;
+
+ if (! matched) {
+ return null;
+ }
+
+ fields = matched.fields || [];
+ }
+
+ return matched;
}
function defaultValue(field) {
- if (Object.prototype.hasOwnProperty.call(field, 'default')) return field.default;
- if (field.type === 'boolean') return false;
- if (field.type === 'repeater') return [];
+ if (Object.prototype.hasOwnProperty.call(field, 'default')) {
+ return structuredClone(field.default);
+ }
+
+ if (field.type === 'boolean') {
+ return false;
+ }
+
+ if (field.type === 'repeater') {
+ return [];
+ }
+
return '';
}
+ function newRepeaterItem(field) {
+ const item = {};
+
+ (field.fields || []).forEach(child => {
+ item[child.key] = defaultValue(child);
+ });
+
+ return item;
+ }
+
+ function hydrateMissingFields(fields, target) {
+ (fields || []).forEach(field => {
+ if (! Object.prototype.hasOwnProperty.call(target, field.key)) {
+ target[field.key] = defaultValue(field);
+ }
+
+ if (field.type === 'repeater') {
+ if (! Array.isArray(target[field.key])) {
+ target[field.key] = [];
+ }
+
+ target[field.key].forEach(item => {
+ if (item && typeof item === 'object' && ! Array.isArray(item)) {
+ hydrateMissingFields(field.fields || [], item);
+ }
+ });
+ }
+ });
+ }
+
+ function syncJson() {
+ dataField.value = JSON.stringify(data, null, 2);
+ }
+
function fieldMarkup(field, value, path) {
const help = field.help
? `
${escapeHtml(field.help)}
`
@@ -229,16 +352,18 @@
if (field.type === 'boolean') {
return `
-
+
${escapeHtml(field.label)}
${help}
-
-
-
-
-
+
`;
}
@@ -246,153 +371,256 @@
return `
${escapeHtml(field.label)}
-
- ${help}`;
+
+
+ ${help}`;
}
if (field.type === 'repeater') {
- return repeaterMarkup(field, Array.isArray(value) ? value : [], path) + help;
+ return repeaterMarkup(
+ field,
+ Array.isArray(value) ? value : [],
+ path
+ ) + help;
+ }
+
+ let inputType = 'text';
+
+ if (field.type === 'number') {
+ inputType = 'number';
+ } else if (field.type === 'email') {
+ inputType = 'email';
+ } else if (field.type === 'url') {
+ inputType = 'url';
}
- const type = field.type === 'number' ? 'number' : field.type === 'email' ? 'email' : 'text';
return `
${escapeHtml(field.label)}
- ${help}`;
+
+ ${help}`;
}
function repeaterMarkup(field, items, path) {
return `
-
-
+
+
${escapeHtml(field.label)}
-
Items can be reordered, hidden, or removed.
+
+ Add, edit, reorder, hide or remove items.
+
-
Add item
+
+
+ Add item
+
+
- ${items.length ? items.map((item, index) => `
-
-
-
Item ${index + 1}
-
-
↑
-
↓
-
Remove
+ ${items.length
+ ? items.map((item, index) => `
+
+
+
Item ${index + 1}
+
+
+ ↑
+
+ ↓
+
+
+ Remove
+
+
-
-
- ${(field.fields || []).map(child => `
-
- ${fieldMarkup(child, item?.[child.key] ?? defaultValue(child), `${path}.${index}.${child.key}`)}
-
`).join('')}
-
- `).join('') : '
No items. Select “Add item” to create one.
'}
+
+
+ ${(field.fields || []).map(child => `
+
+ ${fieldMarkup(
+ child,
+ item?.[child.key] ?? defaultValue(child),
+ `${path}.${index}.${child.key}`
+ )}
+
+ `).join('')}
+
+
+ `).join('')
+ : `
+
+ No items. Select “Add item” to create one.
+
+ `}
`;
}
- function render() {
- const schema = normaliseSchema(rawSchemas[String(typeSelect.value)]);
- status.textContent = schema.length ? `${schema.length} editable field${schema.length === 1 ? '' : 's'}` : 'Schema unavailable';
+ function currentSchema() {
+ return normaliseSchema(rawSchemas[String(typeSelect.value)]);
+ }
- if (!schema.length) {
+ function render() {
+ const schema = currentSchema();
+
+ status.textContent = schema.length
+ ? `${schema.length} editable field${schema.length === 1 ? '' : 's'}`
+ : 'Schema unavailable';
+
+ if (! schema.length) {
host.innerHTML = `
- This content type has no usable visual schema. Run php artisan content:install-saasland once to refresh schemas without replacing content.
+ This content type has no usable visual schema.
+ Run php artisan content:install-saasland once.
`;
+
return;
}
+ hydrateMissingFields(schema, data);
+ syncJson();
+
host.innerHTML = schema.map(field => fieldMarkup(
field,
- Object.prototype.hasOwnProperty.call(data, field.key) ? data[field.key] : defaultValue(field),
+ data[field.key],
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.addEventListener('input', event => {
+ const element = event.target.closest('[data-path]');
- 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();
- });
- });
+ if (! element || element.type === 'checkbox') {
+ return;
+ }
- 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();
- });
- });
+ let value = element.value;
- 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();
- });
- });
- }
+ if (element.type === 'number') {
+ value = element.value === '' ? null : Number(element.value);
+ }
+
+ setAtPath(element.dataset.path, value);
+ });
+
+ host.addEventListener('change', event => {
+ const element = event.target.closest('[data-path]');
+
+ if (! element || element.type !== 'checkbox') {
+ return;
+ }
+
+ setAtPath(element.dataset.path, element.checked);
+ });
+
+ host.addEventListener('click', event => {
+ const button = event.target.closest('button[data-action]');
+
+ if (! button) {
+ return;
+ }
+
+ event.preventDefault();
+
+ const schema = currentSchema();
+ const action = button.dataset.action;
+ const path = button.dataset.path;
+ const field = fieldForPath(schema, path);
+ const items = Array.isArray(getAtPath(path))
+ ? [...getAtPath(path)]
+ : [];
+
+ if (action === 'add') {
+ if (! field || field.type !== 'repeater') {
+ return;
+ }
+
+ items.push(newRepeaterItem(field));
+ setAtPath(path, items);
+ render();
+
+ return;
+ }
+
+ const index = Number(button.dataset.index);
+
+ if (! Number.isInteger(index) || index < 0 || index >= items.length) {
+ return;
+ }
+
+ if (action === 'remove') {
+ if (! window.confirm('Remove this item?')) {
+ return;
+ }
+
+ items.splice(index, 1);
+ }
+
+ if (action === 'up' && index > 0) {
+ [items[index - 1], items[index]] = [items[index], items[index - 1]];
+ }
+
+ if (action === 'down' && index < items.length - 1) {
+ [items[index], items[index + 1]] = [items[index + 1], items[index]];
+ }
+
+ setAtPath(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();
+ data = {};
+ hydrateMissingFields(currentSchema(), data);
+ syncJson();
render();
});
dataField.addEventListener('change', () => {
- try {
- data = parseObject(dataField.value);
- render();
- } catch (error) {
- alert(`Invalid JSON: ${error.message}`);
- }
+ data = parseObject(dataField.value);
+ render();
+ });
+
+ form.addEventListener('submit', () => {
+ syncJson();
});
- form.addEventListener('submit', sync);
render();
});
diff --git a/resources/themes/saasland/views/documents/site-footer.blade.php b/resources/themes/saasland/views/documents/site-footer.blade.php
index 9a69066..9f989ac 100644
--- a/resources/themes/saasland/views/documents/site-footer.blade.php
+++ b/resources/themes/saasland/views/documents/site-footer.blade.php
@@ -1 +1,3 @@
-@include('stylist-theme-saasland::partials.footer')
+@if (filter_var($data['enabled'] ?? true, FILTER_VALIDATE_BOOL))
+ @include('stylist-theme-saasland::partials.footer', ['footerContent' => $data])
+@endif
diff --git a/resources/themes/saasland/views/documents/site-header.blade.php b/resources/themes/saasland/views/documents/site-header.blade.php
index 0812161..0c65272 100644
--- a/resources/themes/saasland/views/documents/site-header.blade.php
+++ b/resources/themes/saasland/views/documents/site-header.blade.php
@@ -1 +1,3 @@
-@include('stylist-theme-saasland::partials.header')
+@if (filter_var($data['enabled'] ?? true, FILTER_VALIDATE_BOOL))
+ @include('stylist-theme-saasland::partials.header', ['headerContent' => $data])
+@endif
diff --git a/resources/themes/saasland/views/partials/footer.blade.php b/resources/themes/saasland/views/partials/footer.blade.php
index 3d95dcc..f70ec6f 100644
--- a/resources/themes/saasland/views/partials/footer.blade.php
+++ b/resources/themes/saasland/views/partials/footer.blade.php
@@ -1,36 +1,43 @@
-