feat(footer): add customizable footer component with dynamic content and links

This commit is contained in:
Snider 2026-01-26 20:46:49 +00:00
parent b0e3ef461f
commit 294e73e189
8 changed files with 448 additions and 36 deletions

View file

@ -94,21 +94,17 @@ class ConfigExportCommand extends Command
/**
* Get autocompletion suggestions.
*
* @return array<string>
*/
public function complete(CompletionInput $input, array $suggestions): array
public function complete(CompletionInput $input, \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestOptionValuesFor('workspace')) {
if (class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
return \Core\Mod\Tenant\Models\Workspace::pluck('slug')->toArray();
$suggestions->suggestValues(\Core\Mod\Tenant\Models\Workspace::pluck('slug')->toArray());
}
}
if ($input->mustSuggestOptionValuesFor('category')) {
return \Core\Config\Models\ConfigKey::distinct()->pluck('category')->toArray();
$suggestions->suggestValues(\Core\Config\Models\ConfigKey::distinct()->pluck('category')->toArray());
}
return $suggestions;
}
}

View file

@ -172,17 +172,13 @@ class ConfigImportCommand extends Command
/**
* Get autocompletion suggestions.
*
* @return array<string>
*/
public function complete(CompletionInput $input, array $suggestions): array
public function complete(CompletionInput $input, \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestOptionValuesFor('workspace')) {
if (class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
return \Core\Mod\Tenant\Models\Workspace::pluck('slug')->toArray();
$suggestions->suggestValues(\Core\Mod\Tenant\Models\Workspace::pluck('slug')->toArray());
}
}
return $suggestions;
}
}

View file

@ -400,21 +400,17 @@ class ConfigVersionCommand extends Command
/**
* Get autocompletion suggestions.
*
* @return array<string>
*/
public function complete(CompletionInput $input, array $suggestions): array
public function complete(CompletionInput $input, \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('action')) {
return ['list', 'create', 'show', 'rollback', 'compare', 'diff', 'delete'];
$suggestions->suggestValues(['list', 'create', 'show', 'rollback', 'compare', 'diff', 'delete']);
}
if ($input->mustSuggestOptionValuesFor('workspace')) {
if (class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
return \Core\Mod\Tenant\Models\Workspace::pluck('slug')->toArray();
$suggestions->suggestValues(\Core\Mod\Tenant\Models\Workspace::pluck('slug')->toArray());
}
}
return $suggestions;
}
}

View file

@ -0,0 +1,114 @@
{{--
Custom Footer Content Partial
Variables:
$customContent - Raw HTML content
$customLinks - Array of ['label' => '', 'url' => '', 'icon' => ''] links
$socialLinks - Array of ['platform' => '', 'url' => '', 'icon' => ''] social links
$contactEmail - Email address
$contactPhone - Phone number
$showCopyright - Whether to show copyright (for replace mode)
$copyrightText - Custom copyright text
$workspaceName - Workspace name for default copyright
$appName - App name for default copyright
$appIcon - App icon path
--}}
@php
$showCopyright = $showCopyright ?? false;
$copyrightText = $copyrightText ?? null;
$workspaceName = $workspaceName ?? null;
$appName = $appName ?? config('core.app.name', 'Core PHP');
$appIcon = $appIcon ?? config('core.app.icon', '/images/icon.svg');
@endphp
<div class="max-w-5xl mx-auto px-4 sm:px-6 py-8">
{{-- Raw HTML custom content --}}
@if(!empty($customContent))
<div class="custom-footer-content mb-6">
{!! $customContent !!}
</div>
@endif
{{-- Structured content grid --}}
@if(!empty($customLinks) || !empty($socialLinks) || $contactEmail || $contactPhone)
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-6 @if(!empty($customContent)) pt-6 border-t border-slate-200 dark:border-slate-700/50 @endif">
{{-- Contact information --}}
@if($contactEmail || $contactPhone)
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-4 text-sm text-slate-500">
@if($contactEmail)
<a href="mailto:{{ $contactEmail }}" class="hover:text-slate-700 dark:hover:text-slate-300 transition flex items-center gap-2">
<i class="fa-solid fa-envelope text-xs"></i>
{{ $contactEmail }}
</a>
@endif
@if($contactPhone)
<a href="tel:{{ preg_replace('/[^0-9+]/', '', $contactPhone) }}" class="hover:text-slate-700 dark:hover:text-slate-300 transition flex items-center gap-2">
<i class="fa-solid fa-phone text-xs"></i>
{{ $contactPhone }}
</a>
@endif
</div>
@endif
{{-- Custom links --}}
@if(!empty($customLinks))
<div class="flex flex-wrap items-center gap-4 text-sm text-slate-500">
@foreach($customLinks as $link)
<a href="{{ $link['url'] }}" class="hover:text-slate-700 dark:hover:text-slate-300 transition flex items-center gap-2">
@if(!empty($link['icon']))
<i class="{{ $link['icon'] }} text-xs"></i>
@endif
{{ $link['label'] }}
</a>
@endforeach
</div>
@endif
{{-- Social links --}}
@if(!empty($socialLinks))
<div class="flex items-center gap-4">
@foreach($socialLinks as $social)
@php
// Get icon from the social link or generate based on platform
$socialIcon = $social['icon'] ?? match(strtolower($social['platform'] ?? '')) {
'twitter', 'x' => 'fa-brands fa-x-twitter',
'facebook' => 'fa-brands fa-facebook',
'instagram' => 'fa-brands fa-instagram',
'linkedin' => 'fa-brands fa-linkedin',
'youtube' => 'fa-brands fa-youtube',
'tiktok' => 'fa-brands fa-tiktok',
'github' => 'fa-brands fa-github',
'discord' => 'fa-brands fa-discord',
'mastodon' => 'fa-brands fa-mastodon',
'bluesky' => 'fa-brands fa-bluesky',
'threads' => 'fa-brands fa-threads',
'pinterest' => 'fa-brands fa-pinterest',
default => 'fa-solid fa-link',
};
@endphp
<a
href="{{ $social['url'] }}"
target="_blank"
rel="noopener noreferrer"
class="w-8 h-8 flex items-center justify-center rounded-full text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
aria-label="{{ ucfirst($social['platform'] ?? 'Social link') }}"
>
<i class="{{ $socialIcon }}"></i>
</a>
@endforeach
</div>
@endif
</div>
@endif
{{-- Copyright for replace mode --}}
@if($showCopyright)
<div class="flex items-center gap-4 mt-6 pt-6 border-t border-slate-200 dark:border-slate-700/50">
<img src="{{ $appIcon }}" alt="{{ $appName }}" class="w-6 h-6 opacity-50">
<span class="text-sm text-slate-500">
{!! $copyrightText ?? '&copy; '.date('Y').' '.e($workspaceName ?? $appName) !!}
</span>
</div>
@endif
</div>

View file

@ -4,6 +4,18 @@
$appUrl = config('app.url', 'https://core.test');
$privacyUrl = config('core.urls.privacy', '/privacy');
$termsUrl = config('core.urls.terms', '/terms');
// Footer settings - can be passed as array or FooterSettings object
$footer = $footer ?? null;
$footerShowDefault = $footer['show_default_links'] ?? $footer?->showDefaultLinks ?? true;
$footerPosition = $footer['position'] ?? $footer?->position ?? 'above_default';
$footerCustomContent = $footer['custom_content'] ?? $footer?->customContent ?? null;
$footerCustomLinks = $footer['custom_links'] ?? $footer?->customLinks ?? [];
$footerSocialLinks = $footer['social_links'] ?? $footer?->socialLinks ?? [];
$footerCopyright = $footer['copyright_text'] ?? $footer?->copyrightText ?? null;
$footerContactEmail = $footer['contact_email'] ?? $footer?->contactEmail ?? null;
$footerContactPhone = $footer['contact_phone'] ?? $footer?->contactPhone ?? null;
$footerHasCustom = $footerCustomContent || !empty($footerCustomLinks) || !empty($footerSocialLinks) || $footerContactEmail || $footerContactPhone;
@endphp
<!DOCTYPE html>
<html lang="en" class="scroll-smooth overscroll-none"
@ -145,24 +157,66 @@
<footer class="mt-auto">
<!-- Footer gradient border -->
<div class="h-px w-full bg-gradient-to-r from-transparent via-violet-500/20 to-transparent"></div>
<div class="max-w-5xl mx-auto px-4 sm:px-6 py-8">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-4">
<img src="{{ $appIcon }}" alt="{{ $appName }}" class="w-6 h-6 opacity-50">
<span class="text-sm text-slate-500">
&copy; {{ date('Y') }} {{ $workspace?->name ?? $appName }}
</span>
</div>
<div class="flex items-center gap-6 text-sm text-slate-500">
<a href="{{ $privacyUrl }}" class="hover:text-slate-700 dark:hover:text-slate-300 transition">Privacy</a>
<a href="{{ $termsUrl }}" class="hover:text-slate-700 dark:hover:text-slate-300 transition">Terms</a>
<a href="{{ $appUrl }}" class="hover:text-slate-700 dark:hover:text-slate-300 transition flex items-center gap-1">
<i class="fa-solid fa-bolt text-violet-400 text-xs"></i>
Powered by {{ $appName }}
</a>
{{-- Custom footer content (above default) --}}
@if($footerHasCustom && $footerPosition === 'above_default')
@include('front::components.satellite.footer-custom', [
'customContent' => $footerCustomContent,
'customLinks' => $footerCustomLinks,
'socialLinks' => $footerSocialLinks,
'contactEmail' => $footerContactEmail,
'contactPhone' => $footerContactPhone,
])
@endif
{{-- Custom footer content (replace default) --}}
@if($footerHasCustom && $footerPosition === 'replace_default')
@include('front::components.satellite.footer-custom', [
'customContent' => $footerCustomContent,
'customLinks' => $footerCustomLinks,
'socialLinks' => $footerSocialLinks,
'contactEmail' => $footerContactEmail,
'contactPhone' => $footerContactPhone,
'showCopyright' => true,
'copyrightText' => $footerCopyright,
'workspaceName' => $workspace?->name,
'appName' => $appName,
'appIcon' => $appIcon,
])
@endif
{{-- Default footer --}}
@if($footerShowDefault && $footerPosition !== 'replace_default')
<div class="max-w-5xl mx-auto px-4 sm:px-6 py-8">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-4">
<img src="{{ $appIcon }}" alt="{{ $appName }}" class="w-6 h-6 opacity-50">
<span class="text-sm text-slate-500">
{!! $footerCopyright ?? '&copy; '.date('Y').' '.e($workspace?->name ?? $appName) !!}
</span>
</div>
<div class="flex items-center gap-6 text-sm text-slate-500">
<a href="{{ $privacyUrl }}" class="hover:text-slate-700 dark:hover:text-slate-300 transition">Privacy</a>
<a href="{{ $termsUrl }}" class="hover:text-slate-700 dark:hover:text-slate-300 transition">Terms</a>
<a href="{{ $appUrl }}" class="hover:text-slate-700 dark:hover:text-slate-300 transition flex items-center gap-1">
<i class="fa-solid fa-bolt text-violet-400 text-xs"></i>
Powered by {{ $appName }}
</a>
</div>
</div>
</div>
</div>
@endif
{{-- Custom footer content (below default) --}}
@if($footerHasCustom && $footerPosition === 'below_default')
@include('front::components.satellite.footer-custom', [
'customContent' => $footerCustomContent,
'customLinks' => $footerCustomLinks,
'socialLinks' => $footerSocialLinks,
'contactEmail' => $footerContactEmail,
'contactPhone' => $footerContactPhone,
])
@endif
</footer>
</body>

View file

@ -2,6 +2,7 @@
declare(strict_types=1);
use Core\Website\Service\View\Features;
use Core\Website\Service\View\Landing;
use Illuminate\Support\Facades\Route;
@ -16,3 +17,4 @@ use Illuminate\Support\Facades\Route;
*/
Route::get('/', Landing::class)->name('service.home');
Route::get('/features', Features::class)->name('service.features');

View file

@ -0,0 +1,92 @@
<div>
{{-- Hero Section --}}
<section class="relative overflow-hidden">
<div class="relative max-w-7xl mx-auto px-6 md:px-10 xl:px-8 py-20 lg:py-28">
<div class="text-center max-w-3xl mx-auto">
{{-- Badge --}}
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full text-sm mb-6" style="background-color: color-mix(in srgb, var(--service-accent) 15%, transparent); border: 1px solid color-mix(in srgb, var(--service-accent) 30%, transparent); color: var(--service-accent);">
<i class="fa-solid fa-{{ $workspace['icon'] ?? 'cube' }}"></i>
{{ $workspace['name'] ?? 'Service' }} Features
</div>
{{-- Headline --}}
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 leading-tight">
Everything you need to
<span style="color: var(--service-accent);">succeed</span>
</h1>
{{-- Description --}}
<p class="text-lg text-slate-400 mb-8 max-w-xl mx-auto">
{{ $workspace['description'] ?? 'Powerful features built for creators and businesses.' }}
</p>
</div>
</div>
</section>
{{-- Features Grid --}}
<section class="py-20 lg:py-28 bg-slate-900/50">
<div class="max-w-7xl mx-auto px-6 md:px-10 xl:px-8">
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
@foreach($features as $feature)
<div class="rounded-2xl p-8 transition group" style="background-color: color-mix(in srgb, var(--service-accent) 5%, rgb(30 41 59)); border: 1px solid color-mix(in srgb, var(--service-accent) 15%, transparent);">
<div class="w-14 h-14 rounded-xl flex items-center justify-center mb-6 transition" style="background-color: color-mix(in srgb, var(--service-accent) 15%, transparent);">
<i class="fa-solid fa-{{ $feature['icon'] }} text-xl" style="color: var(--service-accent);"></i>
</div>
<h3 class="text-xl font-semibold text-white mb-3">{{ $feature['title'] }}</h3>
<p class="text-slate-400 leading-relaxed">{{ $feature['description'] }}</p>
</div>
@endforeach
</div>
</div>
</section>
{{-- Integration Section --}}
<section class="py-20 lg:py-28">
<div class="max-w-4xl mx-auto px-6 md:px-10 xl:px-8 text-center">
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">
Part of the Host UK ecosystem
</h2>
<p class="text-lg text-slate-400 mb-10 max-w-2xl mx-auto">
{{ $workspace['name'] ?? 'This service' }} works seamlessly with other Host UK services.
One account, unified billing, integrated tools.
</p>
<div class="flex flex-wrap justify-center gap-4">
<a href="https://{{ app()->environment('local') ? 'social.host.test' : 'social.host.uk.com' }}" class="px-4 py-2 rounded-lg bg-slate-800 text-slate-300 text-sm hover:bg-slate-700 transition">
<i class="fa-solid fa-share-nodes mr-2"></i>SocialHost
</a>
<a href="https://{{ app()->environment('local') ? 'analytics.host.test' : 'analytics.host.uk.com' }}" class="px-4 py-2 rounded-lg bg-slate-800 text-slate-300 text-sm hover:bg-slate-700 transition">
<i class="fa-solid fa-chart-line mr-2"></i>AnalyticsHost
</a>
<a href="https://{{ app()->environment('local') ? 'notify.host.test' : 'notify.host.uk.com' }}" class="px-4 py-2 rounded-lg bg-slate-800 text-slate-300 text-sm hover:bg-slate-700 transition">
<i class="fa-solid fa-bell mr-2"></i>NotifyHost
</a>
<a href="https://{{ app()->environment('local') ? 'trust.host.test' : 'trust.host.uk.com' }}" class="px-4 py-2 rounded-lg bg-slate-800 text-slate-300 text-sm hover:bg-slate-700 transition">
<i class="fa-solid fa-star mr-2"></i>TrustHost
</a>
<a href="https://{{ app()->environment('local') ? 'support.host.test' : 'support.host.uk.com' }}" class="px-4 py-2 rounded-lg bg-slate-800 text-slate-300 text-sm hover:bg-slate-700 transition">
<i class="fa-solid fa-headset mr-2"></i>SupportHost
</a>
</div>
</div>
</section>
{{-- CTA Section --}}
<section class="py-20" style="background: linear-gradient(135deg, var(--service-accent), color-mix(in srgb, var(--service-accent) 70%, black));">
<div class="max-w-4xl mx-auto px-6 md:px-10 xl:px-8 text-center">
<h2 class="text-3xl sm:text-4xl font-bold mb-4 text-slate-900">
Ready to get started?
</h2>
<p class="text-lg mb-8 text-slate-800">
Start your free trial today. No credit card required.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="{{ config('app.url') }}/register" class="inline-flex items-center justify-center px-8 py-3 font-semibold rounded-xl transition shadow-lg bg-slate-900" style="color: var(--service-accent);">
Get started free
</a>
<a href="/pricing" class="inline-flex items-center justify-center px-8 py-3 font-semibold rounded-xl transition bg-white/20 text-slate-900 border border-slate-900/20 hover:bg-white/30">
View pricing
</a>
</div>
</div>
</section>
</div>

View file

@ -0,0 +1,162 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Website\Service\View;
use Illuminate\Contracts\View\View;
use Livewire\Component;
/**
* Generic Service Features Page.
*
* Displays service features with dynamic theming.
*/
class Features extends Component
{
public array $workspace = [];
public function mount(): void
{
// Extract subdomain from host (e.g., social.host.test → social)
$host = request()->getHost();
$slug = $this->extractSubdomain($host);
// Try to resolve workspace from app container if service exists
if ($slug && app()->bound('workspace.service')) {
$this->workspace = app('workspace.service')->get($slug) ?? [];
}
// Fallback to app config defaults
if (empty($this->workspace)) {
$this->workspace = [
'name' => config('core.app.name', config('app.name', 'Service')),
'slug' => 'service',
'icon' => config('core.app.icon', 'cube'),
'color' => config('core.app.color', 'violet'),
'description' => config('core.app.description', 'A powerful platform'),
];
}
}
/**
* Extract subdomain from hostname.
*/
protected function extractSubdomain(string $host): ?string
{
if (preg_match('/^([a-z]+)\.host\.(test|localhost|uk\.com)$/i', $host, $matches)) {
return $matches[1];
}
return null;
}
/**
* Get detailed features for this service.
*/
public function getFeatures(): array
{
$slug = $this->workspace['slug'] ?? 'service';
// Service-specific features
return match ($slug) {
'social' => $this->getSocialFeatures(),
'analytics' => $this->getAnalyticsFeatures(),
'notify' => $this->getNotifyFeatures(),
'trust' => $this->getTrustFeatures(),
'support' => $this->getSupportFeatures(),
default => $this->getDefaultFeatures(),
};
}
protected function getSocialFeatures(): array
{
return [
['icon' => 'calendar', 'title' => 'Schedule posts', 'description' => 'Plan your content calendar weeks in advance with our visual scheduler.'],
['icon' => 'share-nodes', 'title' => 'Multi-platform publishing', 'description' => 'Publish to 20+ social networks from a single dashboard.'],
['icon' => 'chart-pie', 'title' => 'Analytics & insights', 'description' => 'Track engagement, reach, and growth across all your accounts.'],
['icon' => 'users', 'title' => 'Team collaboration', 'description' => 'Work together with approval workflows and role-based access.'],
['icon' => 'wand-magic-sparkles', 'title' => 'AI content assistant', 'description' => 'Generate captions, hashtags, and post ideas with AI.'],
['icon' => 'inbox', 'title' => 'Unified inbox', 'description' => 'Manage comments and messages from all platforms in one place.'],
];
}
protected function getAnalyticsFeatures(): array
{
return [
['icon' => 'cookie-bite', 'title' => 'No cookies required', 'description' => 'Privacy-focused tracking without consent banners.'],
['icon' => 'bolt', 'title' => 'Lightweight script', 'description' => 'Under 1KB script that won\'t slow down your site.'],
['icon' => 'chart-line', 'title' => 'Real-time dashboard', 'description' => 'See visitors on your site as they browse.'],
['icon' => 'route', 'title' => 'Goal tracking', 'description' => 'Set up funnels and conversion goals to measure success.'],
['icon' => 'flask', 'title' => 'A/B testing', 'description' => 'Test variations and measure statistical significance.'],
['icon' => 'file-export', 'title' => 'Data export', 'description' => 'Export your data anytime in CSV or JSON format.'],
];
}
protected function getNotifyFeatures(): array
{
return [
['icon' => 'bell', 'title' => 'Web push notifications', 'description' => 'Reach subscribers directly in their browser.'],
['icon' => 'users-gear', 'title' => 'Audience segments', 'description' => 'Target specific groups based on behaviour and preferences.'],
['icon' => 'diagram-project', 'title' => 'Automation flows', 'description' => 'Create drip campaigns triggered by user actions.'],
['icon' => 'vials', 'title' => 'A/B testing', 'description' => 'Test different messages to optimise engagement.'],
['icon' => 'chart-simple', 'title' => 'Delivery analytics', 'description' => 'Track delivery, clicks, and conversion rates.'],
['icon' => 'clock', 'title' => 'Scheduled sends', 'description' => 'Schedule notifications for optimal delivery times.'],
];
}
protected function getTrustFeatures(): array
{
return [
['icon' => 'comment-dots', 'title' => 'Social proof popups', 'description' => 'Show real-time purchase and signup notifications.'],
['icon' => 'star', 'title' => 'Review collection', 'description' => 'Collect and display customer reviews automatically.'],
['icon' => 'bullseye', 'title' => 'Smart targeting', 'description' => 'Show notifications to the right visitors at the right time.'],
['icon' => 'palette', 'title' => 'Custom styling', 'description' => 'Match notifications to your brand with full CSS control.'],
['icon' => 'chart-column', 'title' => 'Conversion tracking', 'description' => 'Measure the impact on your conversion rates.'],
['icon' => 'plug', 'title' => 'Easy integration', 'description' => 'Add to any website with a single script tag.'],
];
}
protected function getSupportFeatures(): array
{
return [
['icon' => 'inbox', 'title' => 'Shared inbox', 'description' => 'Manage customer emails from a unified team inbox.'],
['icon' => 'book', 'title' => 'Help centre', 'description' => 'Build a self-service knowledge base for customers.'],
['icon' => 'comments', 'title' => 'Live chat widget', 'description' => 'Embed a chat widget on your website for instant support.'],
['icon' => 'clock-rotate-left', 'title' => 'SLA tracking', 'description' => 'Set response time targets and track performance.'],
['icon' => 'reply', 'title' => 'Canned responses', 'description' => 'Save time with pre-written replies for common questions.'],
['icon' => 'tags', 'title' => 'Ticket management', 'description' => 'Organise conversations with tags and custom fields.'],
];
}
protected function getDefaultFeatures(): array
{
return [
['icon' => 'rocket', 'title' => 'Easy to use', 'description' => 'Get started in minutes with our intuitive interface.'],
['icon' => 'shield-check', 'title' => 'Secure by default', 'description' => 'Built with security and privacy at the core.'],
['icon' => 'chart-line', 'title' => 'Analytics included', 'description' => 'Track performance with built-in analytics.'],
['icon' => 'puzzle-piece', 'title' => 'Modular architecture', 'description' => 'Extend with modules to fit your exact needs.'],
['icon' => 'headset', 'title' => 'UK-based support', 'description' => 'Get help from our friendly support team.'],
['icon' => 'code', 'title' => 'Developer friendly', 'description' => 'Full API access and webhook integrations.'],
];
}
public function render(): View
{
$appName = config('core.app.name', config('app.name', 'Service'));
return view('service::features', [
'workspace' => $this->workspace,
'features' => $this->getFeatures(),
])->layout('service::layouts.service', [
'title' => 'Features - ' . ($this->workspace['name'] ?? $appName),
'workspace' => $this->workspace,
]);
}
}