{{ $page->title }}
@if($page->excerpt){{ $page->excerpt }}
@endifdiff --git a/Http/Controllers/Admin/CmsPageController.php b/Http/Controllers/Admin/CmsPageController.php new file mode 100644 index 0000000..e12651d --- /dev/null +++ b/Http/Controllers/Admin/CmsPageController.php @@ -0,0 +1,45 @@ + CmsPage::latest()->paginate(25)]); } + public function create() { return view('admin.cms.form', ['page' => new CmsPage()]); } + public function store(Request $request) { $page = CmsPage::create($this->validated($request)); return redirect()->route('admin.cms.edit', $page)->with('status', 'Page created.'); } + public function edit(CmsPage $page) { return view('admin.cms.form', compact('page')); } + public function update(Request $request, CmsPage $page) { $page->update($this->validated($request, $page)); return back()->with('status', 'Page updated.'); } + public function destroy(CmsPage $page) { $page->delete(); return redirect()->route('admin.cms.index')->with('status', 'Page deleted.'); } + + private function validated(Request $request, ?CmsPage $page = null): array + { + $request->merge(['slug' => Str::slug($request->input('slug') ?: $request->input('title', ''))]); + $data = $request->validate([ + 'title' => ['required','string','max:160'], + 'slug' => ['required','string','max:180', Rule::unique('cms_pages','slug')->ignore($page?->id)], + 'excerpt' => ['nullable','string','max:1000'], + 'body' => ['required','string'], + 'meta_title' => ['nullable','string','max:160'], + 'meta_description' => ['nullable','string','max:320'], + 'meta_keywords' => ['nullable','string','max:500'], + 'canonical_url' => ['nullable','url:http,https','max:2048'], + 'og_title' => ['nullable','string','max:160'], + 'og_description' => ['nullable','string','max:320'], + 'og_image' => ['nullable','url:http,https','max:2048'], + 'sitemap_priority' => ['required','numeric','min:0','max:1'], + 'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])], + ]); + $data['robots_index'] = $request->boolean('robots_index'); + $data['robots_follow'] = $request->boolean('robots_follow'); + $data['include_in_sitemap'] = $request->boolean('include_in_sitemap'); + $data['is_published'] = $request->boolean('is_published'); + $data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null; + return $data; + } +} diff --git a/Http/Controllers/Admin/SeoSettingsController.php b/Http/Controllers/Admin/SeoSettingsController.php new file mode 100644 index 0000000..a7f920d --- /dev/null +++ b/Http/Controllers/Admin/SeoSettingsController.php @@ -0,0 +1,50 @@ + collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]), + 'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true), + ]); + } + + public function update(Request $request): RedirectResponse + { + $data = $request->validate([ + 'seo_site_name' => ['required', 'string', 'max:80'], + 'seo_title_suffix' => ['nullable', 'string', 'max:80'], + 'seo_default_title' => ['required', 'string', 'max:160'], + 'seo_default_description' => ['required', 'string', 'max:320'], + 'seo_default_keywords' => ['nullable', 'string', 'max:500'], + 'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'], + 'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'], + 'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'], + 'seo_organization_name' => ['nullable', 'string', 'max:120'], + 'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'], + 'seo_google_site_verification' => ['nullable', 'string', 'max:255'], + ]); + + foreach (self::KEYS as $key) { + SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null); + } + SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0'); + + return back()->with('status', 'SEO settings updated.'); + } +} diff --git a/Http/Controllers/SeoController.php b/Http/Controllers/SeoController.php new file mode 100644 index 0000000..59603f3 --- /dev/null +++ b/Http/Controllers/SeoController.php @@ -0,0 +1,53 @@ + 'home', 'priority' => '1.0', 'frequency' => 'weekly'], + ['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'], + ['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'], + ['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'], + ['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'], + ['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'], + ['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'], + ['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'], + ['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'], + ])->filter(fn (array $item) => Route::has($item['route']))->values(); + + $pages = CmsPage::query() + ->where('is_published', true) + ->where('include_in_sitemap', true) + ->where('robots_index', true) + ->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now())) + ->orderBy('slug') + ->get(); + + return response() + ->view('seo.sitemap', compact('static', 'pages')) + ->header('Content-Type', 'application/xml; charset=UTF-8') + ->header('Cache-Control', 'public, max-age=3600'); + } + + public function robots(): Response + { + $indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true); + + $body = $indexable + ? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n" + : "User-agent: *\nDisallow: /\n"; + + return response($body, 200, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Cache-Control' => 'public, max-age=3600', + ]); + } +} diff --git a/Models/CmsPage.php b/Models/CmsPage.php new file mode 100644 index 0000000..3209346 --- /dev/null +++ b/Models/CmsPage.php @@ -0,0 +1,22 @@ + 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean', + 'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1', + ]; + } +} diff --git a/Models/SiteSetting.php b/Models/SiteSetting.php new file mode 100644 index 0000000..872204e --- /dev/null +++ b/Models/SiteSetting.php @@ -0,0 +1,32 @@ + static::query()->find($key)?->value ?? $default); + } + + public static function booleanFor(string $key, bool $default = false): bool + { + $value = static::valueFor($key); + return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + + public static function put(string $key, ?string $value): void + { + static::query()->updateOrCreate(['key' => $key], ['value' => $value]); + Cache::forget('sonardesk.setting.'.$key); + } +} diff --git a/app/Http/Controllers/Admin/CmsPageController.php b/app/Http/Controllers/Admin/CmsPageController.php index b0d53ab..e12651d 100644 --- a/app/Http/Controllers/Admin/CmsPageController.php +++ b/app/Http/Controllers/Admin/CmsPageController.php @@ -27,7 +27,17 @@ final class CmsPageController extends Controller 'body' => ['required','string'], 'meta_title' => ['nullable','string','max:160'], 'meta_description' => ['nullable','string','max:320'], + 'meta_keywords' => ['nullable','string','max:500'], + 'canonical_url' => ['nullable','url:http,https','max:2048'], + 'og_title' => ['nullable','string','max:160'], + 'og_description' => ['nullable','string','max:320'], + 'og_image' => ['nullable','url:http,https','max:2048'], + 'sitemap_priority' => ['required','numeric','min:0','max:1'], + 'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])], ]); + $data['robots_index'] = $request->boolean('robots_index'); + $data['robots_follow'] = $request->boolean('robots_follow'); + $data['include_in_sitemap'] = $request->boolean('include_in_sitemap'); $data['is_published'] = $request->boolean('is_published'); $data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null; return $data; diff --git a/app/Http/Controllers/Admin/SeoSettingsController.php b/app/Http/Controllers/Admin/SeoSettingsController.php new file mode 100644 index 0000000..a7f920d --- /dev/null +++ b/app/Http/Controllers/Admin/SeoSettingsController.php @@ -0,0 +1,50 @@ + collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]), + 'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true), + ]); + } + + public function update(Request $request): RedirectResponse + { + $data = $request->validate([ + 'seo_site_name' => ['required', 'string', 'max:80'], + 'seo_title_suffix' => ['nullable', 'string', 'max:80'], + 'seo_default_title' => ['required', 'string', 'max:160'], + 'seo_default_description' => ['required', 'string', 'max:320'], + 'seo_default_keywords' => ['nullable', 'string', 'max:500'], + 'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'], + 'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'], + 'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'], + 'seo_organization_name' => ['nullable', 'string', 'max:120'], + 'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'], + 'seo_google_site_verification' => ['nullable', 'string', 'max:255'], + ]); + + foreach (self::KEYS as $key) { + SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null); + } + SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0'); + + return back()->with('status', 'SEO settings updated.'); + } +} diff --git a/app/Http/Controllers/SeoController.php b/app/Http/Controllers/SeoController.php new file mode 100644 index 0000000..59603f3 --- /dev/null +++ b/app/Http/Controllers/SeoController.php @@ -0,0 +1,53 @@ + 'home', 'priority' => '1.0', 'frequency' => 'weekly'], + ['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'], + ['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'], + ['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'], + ['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'], + ['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'], + ['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'], + ['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'], + ['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'], + ])->filter(fn (array $item) => Route::has($item['route']))->values(); + + $pages = CmsPage::query() + ->where('is_published', true) + ->where('include_in_sitemap', true) + ->where('robots_index', true) + ->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now())) + ->orderBy('slug') + ->get(); + + return response() + ->view('seo.sitemap', compact('static', 'pages')) + ->header('Content-Type', 'application/xml; charset=UTF-8') + ->header('Cache-Control', 'public, max-age=3600'); + } + + public function robots(): Response + { + $indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true); + + $body = $indexable + ? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n" + : "User-agent: *\nDisallow: /\n"; + + return response($body, 200, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Cache-Control' => 'public, max-age=3600', + ]); + } +} diff --git a/app/Models/CmsPage.php b/app/Models/CmsPage.php index 180a18e..3209346 100644 --- a/app/Models/CmsPage.php +++ b/app/Models/CmsPage.php @@ -6,10 +6,17 @@ use Illuminate\Database\Eloquent\Model; final class CmsPage extends Model { - protected $fillable = ['title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'is_published', 'published_at']; + protected $fillable = [ + 'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'meta_keywords', + 'canonical_url', 'og_title', 'og_description', 'og_image', 'robots_index', 'robots_follow', + 'include_in_sitemap', 'sitemap_priority', 'sitemap_change_frequency', 'is_published', 'published_at', + ]; protected function casts(): array { - return ['is_published' => 'boolean', 'published_at' => 'datetime']; + return [ + 'is_published' => 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean', + 'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1', + ]; } } diff --git a/app/Models/SiteSetting.php b/app/Models/SiteSetting.php index 83af974..872204e 100644 --- a/app/Models/SiteSetting.php +++ b/app/Models/SiteSetting.php @@ -18,6 +18,12 @@ final class SiteSetting extends Model return Cache::remember('sonardesk.setting.'.$key, 300, fn () => static::query()->find($key)?->value ?? $default); } + public static function booleanFor(string $key, bool $default = false): bool + { + $value = static::valueFor($key); + return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + public static function put(string $key, ?string $value): void { static::query()->updateOrCreate(['key' => $key], ['value' => $value]); diff --git a/database/migrations/2026_10_01_100000_add_professional_seo_to_cms_pages.php b/database/migrations/2026_10_01_100000_add_professional_seo_to_cms_pages.php new file mode 100644 index 0000000..5633841 --- /dev/null +++ b/database/migrations/2026_10_01_100000_add_professional_seo_to_cms_pages.php @@ -0,0 +1,35 @@ +string('meta_keywords', 500)->nullable()->after('meta_description'); + $table->string('canonical_url', 2048)->nullable()->after('meta_keywords'); + $table->string('og_title')->nullable()->after('canonical_url'); + $table->string('og_description', 320)->nullable()->after('og_title'); + $table->string('og_image', 2048)->nullable()->after('og_description'); + $table->boolean('robots_index')->default(true)->after('og_image'); + $table->boolean('robots_follow')->default(true)->after('robots_index'); + $table->boolean('include_in_sitemap')->default(true)->after('robots_follow')->index(); + $table->decimal('sitemap_priority', 2, 1)->default(0.5)->after('include_in_sitemap'); + $table->string('sitemap_change_frequency', 20)->default('monthly')->after('sitemap_priority'); + }); + } + + public function down(): void + { + Schema::table('cms_pages', function (Blueprint $table): void { + $table->dropIndex(['include_in_sitemap']); + $table->dropColumn([ + 'meta_keywords', 'canonical_url', 'og_title', 'og_description', 'og_image', + 'robots_index', 'robots_follow', 'include_in_sitemap', 'sitemap_priority', + 'sitemap_change_frequency', + ]); + }); + } +}; diff --git a/resources/views/admin/cms/form.blade.php b/resources/views/admin/cms/form.blade.php index 2ba7d36..9df446c 100644 --- a/resources/views/admin/cms/form.blade.php +++ b/resources/views/admin/cms/form.blade.php @@ -6,7 +6,17 @@ -
+Override global defaults only where this page needs specific search or social metadata.
Manage licences, activations and customer support.
Licences
Not revoked
Active installations
Portal users
Control how SonarDesk appears in search engines and when pages are shared.
{{ $page->excerpt }}
@endif