diff --git a/Modules/Content/Console/InstallSaaslandContent.php b/Modules/Content/Console/InstallSaaslandContent.php index fdc2462..ba501a6 100644 --- a/Modules/Content/Console/InstallSaaslandContent.php +++ b/Modules/Content/Console/InstallSaaslandContent.php @@ -8,6 +8,8 @@ use Illuminate\Support\Facades\Schema; use Modules\Content\Models\ContentDocument; use Modules\Content\Models\ContentType; use Modules\Content\Models\PageContent; +use Modules\Menus\Models\Menu; +use Modules\Settings\Services\SettingsRepository; final class InstallSaaslandContent extends Command { @@ -26,6 +28,8 @@ final class InstallSaaslandContent extends Command } DB::transaction(function (): void { + $this->installSharedSettings(); + $this->installMenus(); if ($this->option('force')) { PageContent::query()->where('page_key', 'home')->delete(); } @@ -69,6 +73,55 @@ final class InstallSaaslandContent extends Command return self::SUCCESS; } + private function installSharedSettings(): void + { + $settings = app(SettingsRepository::class); + $legacy = []; + + if (Schema::hasTable('stylist_theme_settings')) { + $stored = DB::table('stylist_theme_settings')->where('theme_key', 'saasland')->value('settings'); + $legacy = is_string($stored) ? (json_decode($stored, true) ?: []) : []; + } + + $defaults = [ + 'site_name' => $legacy['site_title'] ?? config('app.name'), + 'site_description' => $legacy['site_description'] ?? 'Modern software for modern companies.', + 'company_name' => $legacy['site_title'] ?? config('app.name'), + 'site_url' => url('/'), + 'logo_url' => $legacy['logo_url'] ?? '', + 'facebook_url' => $legacy['facebook_url'] ?? '', + 'x_url' => $legacy['x_url'] ?? '', + 'linkedin_url' => $legacy['linkedin_url'] ?? '', + 'dashboard_label' => 'Dashboard', + 'login_label' => 'Log in', + 'registration_label' => 'Get started', + ]; + + foreach ($defaults as $key => $value) { + if ($settings->get($key, null, 'site') === null) { + $settings->put($key, $value, 'site', 'string', true); + } + } + } + + private function installMenus(): void + { + if (! Schema::hasTable('menus')) { + return; + } + + $menus = [ + 'primary' => ['Primary navigation', [['enabled' => true, 'label' => 'Home', 'url' => '/'], ['enabled' => true, 'label' => 'Blog', 'url' => '/blog'], ['enabled' => true, 'label' => 'News', 'url' => '/news']]], + 'footer' => ['Footer — Explore', [['enabled' => true, 'label' => 'Home', 'url' => '/'], ['enabled' => true, 'label' => 'Blog', 'url' => '/blog'], ['enabled' => true, 'label' => 'News', 'url' => '/news']]], + 'footer_account' => ['Footer — Account', [['enabled' => true, 'label' => 'Dashboard', 'url' => '/dashboard'], ['enabled' => true, 'label' => 'Profile', 'url' => '/settings/profile']]], + 'footer_legal' => ['Footer — Legal', []], + ]; + + foreach ($menus as $location => [$name, $items]) { + Menu::query()->firstOrCreate(['location' => $location], ['name' => $name, 'items' => $items, 'enabled' => true]); + } + } + private function mergeMissingDefaults(array $current, array $defaults): array { foreach ($defaults as $key => $defaultValue) { @@ -113,44 +166,15 @@ final class InstallSaaslandContent extends Command 'title' => 'Main website header', 'type' => $type('Site header', 'site-header', ['header'], [ $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('menu_location', 'Navigation menu location', 'text', ['help' => 'Usually primary. Edit links from Administration → Menus.']), $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, - '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, - ], + 'data' => ['enabled' => true, 'menu_location' => 'primary', 'show_navigation' => true, 'show_dashboard' => true, 'show_user_name' => true, 'show_login' => true, 'show_registration' => true, 'show_mobile_menu' => true], ], 'hero' => [ 'title' => 'Homepage hero', @@ -204,46 +228,17 @@ final class InstallSaaslandContent extends Command 'title' => 'Main website footer', '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('explore_heading', 'Explore column heading'), + $field('explore_menu_location', 'Explore menu location'), + $field('account_heading', 'Account column heading'), + $field('account_menu_location', 'Account menu location'), + $field('legal_heading', 'Legal column heading'), + $field('legal_menu_location', 'Legal menu location'), $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 ↑', - ], + 'data' => ['enabled' => true, 'explore_heading' => 'Explore', 'explore_menu_location' => 'footer', 'account_heading' => 'Account', 'account_menu_location' => 'footer_account', 'legal_heading' => 'Legal', 'legal_menu_location' => 'footer_legal', 'show_back_to_top' => true, 'back_to_top_label' => 'Back to top ↑'], ], -]; + ]; } } diff --git a/Modules/Menus/Http/Controllers/MenuController.php b/Modules/Menus/Http/Controllers/MenuController.php index eea3155..3154304 100644 --- a/Modules/Menus/Http/Controllers/MenuController.php +++ b/Modules/Menus/Http/Controllers/MenuController.php @@ -5,60 +5,86 @@ namespace Modules\Menus\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; use Illuminate\View\View; +use JsonException; use Modules\Menus\Models\Menu; final class MenuController extends Controller { public function index(): View { - return view('menus::admin.index', ['records' => Menu::query()->latest()->paginate(20)]); + return view('menus::admin.index', ['records' => Menu::query()->orderBy('location')->paginate(30)]); } public function create(): View { - return view('menus::admin.form'); + return view('menus::admin.form', ['locations' => $this->locations()]); } public function store(Request $request): RedirectResponse { - $data = $request->validate(['name' => ['required', 'string', 'max:255'], - 'location' => ['required', 'string', 'max:255'], - 'items' => ['nullable', 'array'], - 'enabled' => ['sometimes', 'boolean']]); - if ($request->filled('items_json')) { - $data['items'] = json_decode($request->string('items_json')->toString(), true, 512, JSON_THROW_ON_ERROR); - } - $data['enabled'] = $request->boolean('enabled'); - Menu::create($data); + Menu::create($this->validated($request)); - return redirect()->route('administration.menus.index')->with('status', 'Menu created.'); + return redirect()->route('administration.menus.index')->with('status', 'Menu created. Every header or footer using this location has been updated.'); } public function edit(Menu $menu): View { - return view('menus::admin.form', ['record' => $menu]); + return view('menus::admin.form', ['record' => $menu, 'locations' => $this->locations()]); } public function update(Request $request, Menu $menu): RedirectResponse { - $data = $request->validate(['name' => ['required', 'string', 'max:255'], - 'location' => ['required', 'string', 'max:255'], - 'items' => ['nullable', 'array'], - 'enabled' => ['sometimes', 'boolean']]); - if ($request->filled('items_json')) { - $data['items'] = json_decode($request->string('items_json')->toString(), true, 512, JSON_THROW_ON_ERROR); - } - $data['enabled'] = $request->boolean('enabled'); - $menu->update($data); + $menu->update($this->validated($request)); - return redirect()->route('administration.menus.index')->with('status', 'Menu updated.'); + return redirect()->route('administration.menus.index')->with('status', 'Menu updated everywhere it is used.'); } public function destroy(Menu $menu): RedirectResponse { $menu->delete(); - return back()->with('status', 'Menu deleted.'); + return back()->with('status', 'Menu deleted. Components using its location will use their safe fallback.'); + } + + /** @return array */ + private function validated(Request $request): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'location' => ['required', 'string', 'max:100', 'regex:/^[a-z0-9_-]+$/'], + 'items_json' => ['nullable', 'string'], + 'enabled' => ['nullable', 'boolean'], + ]); + + try { + $items = json_decode($data['items_json'] ?: '[]', true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + throw ValidationException::withMessages(['items_json' => 'The menu items could not be saved. Please reload the editor and try again.']); + } + + if (! is_array($items)) { + throw ValidationException::withMessages(['items_json' => 'Menu items must be a list.']); + } + + return [ + 'name' => $data['name'], + 'location' => $data['location'], + 'items' => array_values($items), + 'enabled' => $request->boolean('enabled'), + ]; + } + + /** @return array */ + private function locations(): array + { + return [ + 'primary' => 'Primary navigation', + 'footer' => 'Footer — Explore', + 'footer_account' => 'Footer — Account', + 'footer_legal' => 'Footer — Legal', + 'mobile' => 'Mobile navigation override', + ]; } } diff --git a/Modules/Menus/Resources/views/admin/form.blade.php b/Modules/Menus/Resources/views/admin/form.blade.php index 8203074..73a9d39 100644 --- a/Modules/Menus/Resources/views/admin/form.blade.php +++ b/Modules/Menus/Resources/views/admin/form.blade.php @@ -1,8 +1,57 @@ - -

{{ isset($record) ? 'Edit' : 'Create' }} Menu

@csrf @isset($record) @method('PUT') @endisset -
@error('name')

{{ $message }}

@enderror
-
@error('location')

{{ $message }}

@enderror
-
- -
Cancel
+ + @php($initialItems = old('items_json', json_encode($record->items ?? [], JSON_UNESCAPED_SLASHES))) +
+
+

Reusable navigation

+

{{ isset($record) ? 'Edit' : 'Create' }} menu

+

Edit links once. Every header and footer assigned to this menu location updates automatically.

+
+ +
+ @csrf + @isset($record) @method('PUT') @endisset + +
+ + + +
+ +
+

Menu items

Relative paths such as /blog, anchors and full external URLs are supported.

+ + @error('items_json')

{{ $message }}

@enderror + +
+ +
This menu has no links. Add the first link above.
+
+
+ +
CancelSave menu
+
+
+ + @push('scripts') + + @endpush
diff --git a/Modules/Menus/Resources/views/admin/index.blade.php b/Modules/Menus/Resources/views/admin/index.blade.php index 2c51b25..db7b2a4 100644 --- a/Modules/Menus/Resources/views/admin/index.blade.php +++ b/Modules/Menus/Resources/views/admin/index.blade.php @@ -1,5 +1,14 @@ -

Menus

Manage menus content.

Add new
-@if(session('status'))
{{ session('status') }}
@endif -
@forelse($records as $record)@empty@endforelse
NameLocationItemsActions
{{ is_array($record->name) ? json_encode($record->name) : Str::limit((string) $record->name, 70) }}{{ is_array($record->location) ? json_encode($record->location) : Str::limit((string) $record->location, 70) }}{{ is_array($record->items) ? json_encode($record->items) : Str::limit((string) $record->items, 70) }}Edit
@csrf @method('DELETE')
No records yet.
{{ $records->links() }}
+
+

Single source of navigation

Menus

Reusable menus update every header and footer that references their location.

+ @if(session('status'))
{{ session('status') }}
@endif +
+ @forelse($records as $record) +
{{ $record->location }}

{{ $record->name }}

{{ count($record->items ?? []) }} links · {{ $record->enabled ? 'Enabled' : 'Disabled' }}

Edit menu
@csrf @method('DELETE')
+ @empty +

No menus yet

Run the Saasland installer or create your first reusable menu.

+ @endforelse +
+ {{ $records->links() }} +
diff --git a/Modules/Menus/Tests/Feature/MenuEditorTest.php b/Modules/Menus/Tests/Feature/MenuEditorTest.php new file mode 100644 index 0000000..a90f9ba --- /dev/null +++ b/Modules/Menus/Tests/Feature/MenuEditorTest.php @@ -0,0 +1,8 @@ +create(['name' => 'Primary', 'location' => 'primary', 'enabled' => true, 'items' => [['enabled' => true, 'label' => 'Home', 'url' => '/']]]); + expect(app(\Modules\Stylist\Support\FrontendNavigation::class)->primary())->toHaveCount(1)->and(app(\Modules\Stylist\Support\FrontendNavigation::class)->primary()[0]['label'])->toBe('Home'); +}); diff --git a/Modules/Settings/Http/Controllers/SettingController.php b/Modules/Settings/Http/Controllers/SettingController.php index 503c331..c013ff9 100644 --- a/Modules/Settings/Http/Controllers/SettingController.php +++ b/Modules/Settings/Http/Controllers/SettingController.php @@ -3,25 +3,79 @@ namespace Modules\Settings\Http\Controllers; use App\Http\Controllers\Controller; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Modules\Settings\Models\Setting; +use Illuminate\View\View; +use Modules\Settings\Services\SettingsRepository; final class SettingController extends Controller { - public function index() + public function index(SettingsRepository $settings): View { - $settings = Setting::query()->orderBy('group')->orderBy('key')->get(); - - return view('settings::index', compact('settings')); + return view('settings::index', [ + 'values' => $settings->site(), + 'sections' => $this->sections(), + ]); } - public function update(Request $request) + public function update(Request $request, SettingsRepository $settings): RedirectResponse { - $data = $request->validate(['settings' => ['array'], 'settings.*' => ['nullable', 'string', 'max:5000']]); - foreach ($data['settings'] ?? [] as $id => $value) { - Setting::whereKey($id)->update(['value' => $value]); + $rules = []; + foreach ($this->sections() as $fields) { + foreach ($fields as $key => $definition) { + $rules["settings.$key"] = $definition['rules']; + } } -return back()->with('status', 'Settings saved.'); + $validated = $request->validate($rules); + foreach ($validated['settings'] ?? [] as $key => $value) { + $settings->put($key, $value, 'site', 'string', true); + } + + return back()->with('status', 'Site settings saved. The frontend has been updated everywhere.'); + } + + /** @return array>> */ + private function sections(): array + { + $text = ['type' => 'text', 'rules' => ['nullable', 'string', 'max:255']]; + $url = ['type' => 'text', 'rules' => ['nullable', 'string', 'max:2048'], 'placeholder' => '/path or https://example.com']; + $textarea = ['type' => 'textarea', 'rules' => ['nullable', 'string', 'max:5000']]; + + return [ + 'General' => [ + 'site_name' => $text + ['label' => 'Site name', 'help' => 'Used in the header, footer, browser titles and metadata.'], + 'tagline' => $text + ['label' => 'Tagline'], + 'site_description' => $textarea + ['label' => 'Site description'], + 'company_name' => $text + ['label' => 'Legal company name'], + 'site_url' => $url + ['label' => 'Canonical site URL'], + 'copyright' => $text + ['label' => 'Copyright text', 'help' => 'Leave blank to generate it automatically.'], + ], + 'Branding' => [ + 'logo_url' => $url + ['label' => 'Primary logo'], + 'logo_dark_url' => $url + ['label' => 'Dark-mode logo'], + 'favicon_url' => $url + ['label' => 'Favicon'], + ], + 'Contact' => [ + 'contact_email' => ['type' => 'email', 'rules' => ['nullable', 'email', 'max:255'], 'label' => 'Public email'], + 'contact_phone' => $text + ['label' => 'Public telephone'], + 'address' => $textarea + ['label' => 'Address'], + ], + 'Social' => [ + 'facebook_url' => $url + ['label' => 'Facebook'], + 'x_url' => $url + ['label' => 'X / Twitter'], + 'linkedin_url' => $url + ['label' => 'LinkedIn'], + 'instagram_url' => $url + ['label' => 'Instagram'], + ], + 'Account navigation' => [ + 'dashboard_label' => $text + ['label' => 'Dashboard label'], + 'login_label' => $text + ['label' => 'Login label'], + 'registration_label' => $text + ['label' => 'Registration label'], + ], + 'SEO defaults' => [ + 'default_meta_title' => $text + ['label' => 'Default meta title', 'help' => 'Leave blank to use the site name.'], + 'default_meta_description' => $textarea + ['label' => 'Default meta description', 'help' => 'Leave blank to use the site description.'], + ], + ]; } } diff --git a/Modules/Settings/Resources/views/index.blade.php b/Modules/Settings/Resources/views/index.blade.php index fd17a01..610b91d 100644 --- a/Modules/Settings/Resources/views/index.blade.php +++ b/Modules/Settings/Resources/views/index.blade.php @@ -1 +1,49 @@ -

Platform settings

Shared configuration stored by the Settings module.

@if(session('status'))
{{ session('status') }}
@endif
@csrf @method('PUT')@forelse($settings->groupBy('group') as $group=>$items)

{{ $group }}

@foreach($items as $setting)@endforeach
@empty
No settings have been registered yet. SEO defaults can be configured from the SEO screen.
@endforelse
Save settings
+ +
+
+
+

Single source of truth

+

Site settings

+

+ Global identity, branding, contact details and SEO are entered once here and reused by every theme, page, header and footer. +

+
+ Manage menus +
+ + @if (session('status')) +
{{ session('status') }}
+ @endif + +
+ @csrf + @method('PUT') + + @foreach ($sections as $section => $fields) +
+
+

{{ $section }}

+
+
+ @foreach ($fields as $key => $field) + + @endforeach +
+
+ @endforeach + +
+ Save site settings +
+
+
+
diff --git a/Modules/Settings/Services/SettingsRepository.php b/Modules/Settings/Services/SettingsRepository.php index cd520cc..9f60204 100644 --- a/Modules/Settings/Services/SettingsRepository.php +++ b/Modules/Settings/Services/SettingsRepository.php @@ -3,20 +3,127 @@ namespace Modules\Settings\Services; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Schema; use Modules\Settings\Models\Setting; final class SettingsRepository { public function get(string $key, mixed $default = null, string $group = 'general'): mixed { - return Cache::rememberForever("settings.$group.$key", fn () => Setting::query()->where(compact('group', 'key'))->value('value') ?? $default); + if (! $this->tableExists()) { + return $default; + } + + $setting = Cache::rememberForever( + "settings.$group.$key", + fn (): ?Setting => Setting::query() + ->where([ + 'group' => $group, + 'key' => $key, + ]) + ->first() + ); + + return $setting ? $this->decode($setting->value, $setting->type) : $default; + } + + /** @return array */ + public function group(string $group): array + { + if (! $this->tableExists()) { + return []; + } + + return Cache::rememberForever("settings.group.$group", function () use ($group): array { + return Setting::query() + ->where('group', $group) + ->get() + ->mapWithKeys(fn (Setting $setting): array => [ + $setting->key => $this->decode($setting->value, $setting->type), + ]) + ->all(); + }); + } + + /** @return array */ + public function site(): array + { + $defaults = [ + 'site_name' => config('app.name', 'Content Manager'), + 'tagline' => '', + 'site_description' => 'Modern software for modern companies.', + 'company_name' => config('app.name', 'Content Manager'), + 'site_url' => url('/'), + 'logo_url' => '', + 'logo_dark_url' => '', + 'favicon_url' => '/favicon.ico', + 'contact_email' => '', + 'contact_phone' => '', + 'address' => '', + 'facebook_url' => '', + 'x_url' => '', + 'linkedin_url' => '', + 'instagram_url' => '', + 'copyright' => '', + 'dashboard_label' => 'Dashboard', + 'login_label' => 'Log in', + 'registration_label' => 'Get started', + 'default_meta_title' => '', + 'default_meta_description' => '', + ]; + + return array_replace($defaults, $this->group('site')); } public function put(string $key, mixed $value, string $group = 'general', string $type = 'string', bool $public = false): Setting { - $setting = Setting::query()->updateOrCreate(compact('group', 'key'), ['value' => is_scalar($value) || $value === null ? (string) $value : json_encode($value, JSON_THROW_ON_ERROR), 'type' => $type, 'is_public' => $public]); + $setting = Setting::query()->updateOrCreate( + compact('group', 'key'), + [ + 'value' => $this->encode($value, $type), + 'type' => $type, + 'is_public' => $public, + ] + ); + Cache::forget("settings.$group.$key"); + Cache::forget("settings.group.$group"); return $setting; } + + private function tableExists(): bool + { + try { + return Schema::hasTable((new Setting())->getTable()); + } catch (\Throwable) { + return false; + } + } + + private function encode(mixed $value, string $type): ?string + { + if ($value === null) { + return null; + } + + return match ($type) { + 'boolean' => filter_var($value, FILTER_VALIDATE_BOOL) ? '1' : '0', + 'integer' => (string) (int) $value, + 'float' => (string) (float) $value, + 'json', 'array' => json_encode($value, JSON_THROW_ON_ERROR), + default => (string) $value, + }; + } + + private function decode(?string $value, string $type): mixed + { + return match ($type) { + 'boolean' => filter_var($value, FILTER_VALIDATE_BOOL), + 'integer' => (int) $value, + 'float' => (float) $value, + 'json', 'array' => filled($value) ? json_decode($value, true, 512, JSON_THROW_ON_ERROR) : [], + default => $value, + }; + } } diff --git a/Modules/Settings/Tests/Feature/SiteSettingsSingleSourceTest.php b/Modules/Settings/Tests/Feature/SiteSettingsSingleSourceTest.php new file mode 100644 index 0000000..41b4f99 --- /dev/null +++ b/Modules/Settings/Tests/Feature/SiteSettingsSingleSourceTest.php @@ -0,0 +1,9 @@ +put('site_name', 'Unified CMS', 'site', 'string', true); + expect($repository->site()['site_name'])->toBe('Unified CMS'); +}); diff --git a/Modules/Stylist/Providers/StylistServiceProvider.php b/Modules/Stylist/Providers/StylistServiceProvider.php index f971f31..31ee054 100644 --- a/Modules/Stylist/Providers/StylistServiceProvider.php +++ b/Modules/Stylist/Providers/StylistServiceProvider.php @@ -20,6 +20,8 @@ use Modules\Stylist\Services\ThemeSettings; use Modules\Stylist\Theme\Loader; use Modules\Stylist\Theme\Stylist; +use Modules\Settings\Services\SettingsRepository; + final class StylistServiceProvider extends ServiceProvider { protected string $moduleName = 'Stylist'; @@ -71,16 +73,18 @@ final class StylistServiceProvider extends ServiceProvider View::composer('stylist-theme-saasland::*', function ($view): void { $theme = $this->app->make(Loader::class)->find('saasland'); $settings = $this->app->make(ThemeSettings::class)->get($theme); + $siteSettings = $this->app->make(SettingsRepository::class)->site(); $navigation = $this->app->make(FrontendNavigation::class); $homepage = $this->app->make(FrontendContent::class)->homepage($settings); $view->with([ - 'siteTitle' => filled($settings['site_title'] ?? null) ? $settings['site_title'] : config('app.name', 'Saasland'), - 'siteDescription' => filled($settings['site_description'] ?? null) ? $settings['site_description'] : 'Modern software for modern companies.', - 'logoUrl' => $settings['logo_url'] ?? null, - 'facebookUrl' => $settings['facebook_url'] ?? null, - 'xUrl' => $settings['x_url'] ?? null, - 'linkedinUrl' => $settings['linkedin_url'] ?? null, + 'siteSettings' => $siteSettings, + 'siteTitle' => $siteSettings['site_name'], + 'siteDescription' => $siteSettings['site_description'], + 'logoUrl' => $siteSettings['logo_url'] ?: null, + 'facebookUrl' => $siteSettings['facebook_url'] ?: null, + 'xUrl' => $siteSettings['x_url'] ?: null, + 'linkedinUrl' => $siteSettings['linkedin_url'] ?: null, 'primaryNavigation' => $navigation->primary(), 'footerNavigation' => $navigation->footer(), 'themeSettings' => $settings, diff --git a/Modules/Stylist/Support/FrontendNavigation.php b/Modules/Stylist/Support/FrontendNavigation.php index 8bfc071..711f089 100644 --- a/Modules/Stylist/Support/FrontendNavigation.php +++ b/Modules/Stylist/Support/FrontendNavigation.php @@ -12,33 +12,27 @@ final class FrontendNavigation /** @return array> */ public function primary(): array { - return $this->forLocations(['primary', 'header', 'main'], $this->fallbackPrimary()); + return $this->location('primary', $this->fallbackPrimary()); } /** @return array> */ public function footer(): array { - return $this->forLocations(['footer'], $this->fallbackFooter()); + return $this->location('footer', $this->fallbackPrimary()); } - /** @param array $locations - * @param array> $fallback + /** @param array> $fallback * @return array> */ - private function forLocations(array $locations, array $fallback): array + public function location(string $location, array $fallback = []): array { try { if (! Schema::hasTable('menus')) { return $fallback; } - $menu = Menu::query() - ->where('enabled', true) - ->whereIn('location', $locations) - ->orderByRaw('CASE location '.collect($locations)->map(fn (string $location, int $index) => "WHEN ? THEN {$index}")->implode(' ').' ELSE 999 END', $locations) - ->first(); - - if (! $menu || ! is_array($menu->items) || $menu->items === []) { + $menu = Menu::query()->where('enabled', true)->where('location', $location)->first(); + if (! $menu || ! is_array($menu->items)) { return $fallback; } @@ -56,66 +50,27 @@ final class FrontendNavigation private function normaliseItems(array $items): array { return collect($items) - ->filter(fn (mixed $item): bool => is_array($item) && ($item['enabled'] ?? true) !== false) + ->filter(fn (mixed $item): bool => is_array($item) && filter_var($item['enabled'] ?? true, FILTER_VALIDATE_BOOL)) ->map(function (array $item): ?array { - $label = trim((string) ($item['label'] ?? $item['title'] ?? $item['name'] ?? '')); - if ($label === '') { - return null; - } - - $url = $this->resolveUrl($item); - if ($url === null) { - return null; - } + $label = trim((string) ($item['label'] ?? '')); + $url = trim((string) ($item['url'] ?? '')); + if ($label === '' || $url === '') return null; return [ 'label' => $label, 'url' => $url, - 'target' => ($item['target'] ?? null) === '_blank' ? '_blank' : null, - 'children' => $this->normaliseItems((array) ($item['children'] ?? $item['items'] ?? [])), + 'target' => ($item['target'] ?? '') === '_blank' ? '_blank' : null, + 'children' => $this->normaliseItems((array) ($item['children'] ?? [])), ]; - }) - ->filter() - ->values() - ->all(); - } - - /** @param array $item */ - private function resolveUrl(array $item): ?string - { - $routeName = trim((string) ($item['route'] ?? $item['route_name'] ?? '')); - if ($routeName !== '' && Route::has($routeName)) { - return route($routeName, (array) ($item['parameters'] ?? [])); - } - - $pageSlug = trim((string) ($item['page_slug'] ?? $item['slug'] ?? '')); - if ($pageSlug !== '' && Route::has('pages.public.show')) { - return route('pages.public.show', $pageSlug); - } - - $url = trim((string) ($item['url'] ?? $item['href'] ?? '')); - - return $url !== '' ? $url : null; + })->filter()->values()->all(); } /** @return array> */ private function fallbackPrimary(): array { $items = [['label' => 'Home', 'url' => url('/'), 'target' => null, 'children' => []]]; - - if (Route::has('blog.public.index')) { - $items[] = ['label' => 'Blog', 'url' => route('blog.public.index'), 'target' => null, 'children' => []]; - } - if (Route::has('news.public.index')) { - $items[] = ['label' => 'News', 'url' => route('news.public.index'), 'target' => null, 'children' => []]; - } - + if (Route::has('blog.public.index')) $items[] = ['label' => 'Blog', 'url' => route('blog.public.index'), 'target' => null, 'children' => []]; + if (Route::has('news.public.index')) $items[] = ['label' => 'News', 'url' => route('news.public.index'), 'target' => null, 'children' => []]; return $items; } - - /** @return array> */ - private function fallbackFooter(): array - { - return $this->fallbackPrimary(); - } } diff --git a/resources/themes/saasland/views/partials/footer.blade.php b/resources/themes/saasland/views/partials/footer.blade.php index f70ec6f..8d3ba45 100644 --- a/resources/themes/saasland/views/partials/footer.blade.php +++ b/resources/themes/saasland/views/partials/footer.blade.php @@ -1,43 +1,17 @@ @php $footerContent = $footerContent ?? []; - $columns = collect($footerContent['columns'] ?? [])->filter( - fn (array $column): bool => filter_var($column['enabled'] ?? true, FILTER_VALIDATE_BOOL) - ); + $navigation = app(\Modules\Stylist\Support\FrontendNavigation::class); + $columns = collect([ + ['heading' => $footerContent['explore_heading'] ?? 'Explore', 'items' => $navigation->location($footerContent['explore_menu_location'] ?? 'footer', $footerNavigation ?? [])], + ['heading' => $footerContent['account_heading'] ?? 'Account', 'items' => $navigation->location($footerContent['account_menu_location'] ?? 'footer_account')], + ['heading' => $footerContent['legal_heading'] ?? 'Legal', 'items' => $navigation->location($footerContent['legal_menu_location'] ?? 'footer_legal')], + ])->filter(fn(array $column) => $column['items'] !== []); + $copyright = $siteSettings['copyright'] ?: ('© '.date('Y').' '.$siteSettings['company_name'].'. All rights reserved.'); @endphp - - - diff --git a/resources/themes/saasland/views/partials/header.blade.php b/resources/themes/saasland/views/partials/header.blade.php index 6cd451a..53ce6ef 100644 --- a/resources/themes/saasland/views/partials/header.blade.php +++ b/resources/themes/saasland/views/partials/header.blade.php @@ -1,39 +1,19 @@ @php $headerContent = $headerContent ?? []; - $configuredLinks = collect($headerContent['links'] ?? []); - $navigationLinks = $configuredLinks->isNotEmpty() - ? $configuredLinks - : collect($primaryNavigation ?? [])->map(fn (array $item): array => [ - 'enabled' => true, - 'label' => $item['label'] ?? 'Link', - 'url' => $item['url'] ?? '#', - ]); - $navigationLinks = $navigationLinks->filter( - fn (array $link): bool => filter_var($link['enabled'] ?? true, FILTER_VALIDATE_BOOL) - ); + $menuLocation = $headerContent['menu_location'] ?? 'primary'; + $navigationLinks = app(\Modules\Stylist\Support\FrontendNavigation::class)->location($menuLocation, $primaryNavigation ?? []); + $brandUrl = $siteSettings['site_url'] ?: url('/'); @endphp - - - -@if (filter_var($headerContent['show_mobile_menu'] ?? true, FILTER_VALIDATE_BOOL)) -
- @foreach ($navigationLinks as $link) - {{ $link['label'] ?? 'Link' }} - @endforeach -
-@endif +@if (filter_var($headerContent['show_mobile_menu'] ?? true, FILTER_VALIDATE_BOOL))
@foreach($navigationLinks as $link){{ $link['label'] }}@endforeach
@endif