refactor: extract Service + Client to standalone packages
Some checks failed
CI / PHP 8.4 (push) Failing after 1m51s
CI / PHP 8.3 (push) Failing after 1m59s

Core\Service → core/php-service (lthn/service)
Core\Website\Service → core/php-service (lthn/service)
Core\Front\Client → core/php-client (lthn/client)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-09 18:56:43 +00:00
parent dc064cbb61
commit affedb3d46
27 changed files with 0 additions and 3003 deletions

View file

@ -1,24 +0,0 @@
<div class="max-w-4xl mx-auto px-6 py-12">
<h1 class="text-3xl font-bold text-white mb-4">Your Namespace</h1>
<p class="text-zinc-400 mb-8">Manage your space on the internet.</p>
<div class="grid gap-6">
{{-- Namespace Overview --}}
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-2">Overview</h2>
<p class="text-zinc-500 text-sm">Your namespace details will appear here.</p>
</div>
{{-- Quick Actions --}}
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-2">Quick Actions</h2>
<p class="text-zinc-500 text-sm">Edit your bio, view analytics, and more.</p>
</div>
{{-- Recent Activity --}}
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-2">Recent Activity</h2>
<p class="text-zinc-500 text-sm">Your recent activity will appear here.</p>
</div>
</div>
</div>

View file

@ -1,85 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'Dashboard' }} - lt.hn</title>
{{-- Fonts --}}
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700&display=swap" rel="stylesheet">
{{-- Font Awesome --}}
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css">
@vite(['resources/css/admin.css', 'resources/js/app.js'])
@fluxAppearance
</head>
<body class="font-inter antialiased bg-[#070b0b] text-[#cccccb] min-h-screen">
{{-- Header --}}
<header class="sticky top-0 z-50 border-b border-[#40c1c5]/10 bg-[#070b0b]/95 backdrop-blur-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-14">
{{-- Logo + Bio link --}}
<div class="flex items-center gap-4">
<a href="{{ url('/') }}" class="text-xl font-bold text-white">lt.hn</a>
@if(isset($bioUrl))
<span class="text-[#cccccb]/40">/</span>
<a href="{{ url('/' . $bioUrl) }}" class="text-sm text-[#40c1c5] hover:text-[#5dd1d5] transition">
{{ $bioUrl }}
</a>
@endif
</div>
{{-- Nav + User menu --}}
<div class="flex items-center gap-6">
@if(isset($bioUrl))
<nav class="flex items-center gap-1">
@php
$currentPath = request()->path();
$navItems = [
['url' => "/{$bioUrl}/settings", 'label' => 'Editor', 'icon' => 'fa-pen-to-square'],
['url' => "/{$bioUrl}/analytics", 'label' => 'Analytics', 'icon' => 'fa-chart-line'],
['url' => "/{$bioUrl}/submissions", 'label' => 'Submissions', 'icon' => 'fa-inbox'],
['url' => "/{$bioUrl}/qr", 'label' => 'QR Code', 'icon' => 'fa-qrcode'],
];
@endphp
@foreach($navItems as $item)
<a href="{{ url($item['url']) }}"
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition
{{ $currentPath === ltrim($item['url'], '/')
? 'bg-[#40c1c5]/10 text-[#40c1c5]'
: 'text-[#cccccb]/60 hover:text-white hover:bg-white/5' }}">
<i class="fa-solid {{ $item['icon'] }} text-xs"></i>
<span class="hidden sm:inline">{{ $item['label'] }}</span>
</a>
@endforeach
</nav>
@endif
@auth
<div class="flex items-center gap-4 pl-4 border-l border-[#40c1c5]/10">
<a href="{{ url('/dashboard') }}" class="text-sm text-[#cccccb]/60 hover:text-[#40c1c5] transition hidden md:inline">
Dashboard
</a>
<a href="{{ url('/logout') }}" class="text-sm text-[#cccccb]/60 hover:text-white transition">
Sign out
</a>
</div>
@endauth
</div>
</div>
</div>
</header>
{{-- Main content --}}
<main class="min-h-[calc(100vh-3.5rem)]">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{{ $slot }}
</div>
</main>
@fluxScripts
</body>
</html>

View file

@ -1,69 +0,0 @@
<?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\Front\Client;
use Core\Headers\SecurityHeaders;
use Core\LifecycleEventProvider;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
/**
* Client frontage - namespace owner dashboard.
*
* For SaaS customers managing their namespace (personal workspace).
* Not the full Hub/Admin - just YOUR space on the internet.
*
* Hierarchy:
* - Core/Front/Web = Public (anonymous, read-only)
* - Core/Front/Client = SaaS customer (authenticated, namespace owner)
* - Core/Front/Admin = Backend admin (privileged)
* - Core/Hub = SaaS operator (Host.uk.com control plane)
*
* A namespace is tied to a URI/handle (lt.hn/you, you.lthn).
* A workspace (org) can manage multiple namespaces.
* A personal workspace IS your namespace.
*/
class Boot extends ServiceProvider
{
/**
* Configure client middleware group.
*/
public static function middleware(Middleware $middleware): void
{
$middleware->group('client', [
\Illuminate\Cookie\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
SecurityHeaders::class,
'auth',
]);
}
public function register(): void
{
//
}
public function boot(): void
{
// Register client:: namespace for client dashboard components
$this->loadViewsFrom(__DIR__.'/Blade', 'client');
Blade::anonymousComponentPath(__DIR__.'/Blade', 'client');
// Fire ClientRoutesRegistering event for lazy-loaded modules
LifecycleEventProvider::fireClientRoutes();
}
}

View file

@ -1,52 +0,0 @@
# Core/Front/Client
SaaS customer dashboard for namespace owners.
## Concept
```
Core/Front/Web → Public (anonymous, read-only)
Core/Front/Client → SaaS customer (authenticated, namespace owner) ← THIS
Core/Front/Admin → Backend admin (privileged)
Core/Hub → SaaS operator (Host.uk.com control plane)
```
## Namespace vs Workspace
- **Namespace** = your identity, tied to a URI/handle (lt.hn/you, you.lthn)
- **Workspace** = management container (org/agency that can own multiple namespaces)
- **Personal workspace** = IS your namespace (1:1 for solo users)
A user with just a personal workspace uses **Client** to manage their namespace.
An org workspace with multiple namespaces uses **Hub** for team management.
## Use Cases
- Bio page editor (lt.hn/you)
- Analytics dashboard (your stats)
- Domain management (custom domains, web3)
- Settings (profile, notifications)
- Boost purchases (expand namespace entitlements)
## Not For
- Team/org management (use Hub)
- Multi-namespace management (use Hub)
- Backend admin tasks (use Admin)
- Public viewing (use Web)
## Middleware
```php
Route::middleware('client')->group(function () {
// Namespace owner routes
});
```
## Views
```blade
@extends('client::layouts.app')
<x-client::component />
```

View file

@ -1,30 +0,0 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
use Core\Front\Client\View\Dashboard;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Client Routes
|--------------------------------------------------------------------------
|
| Routes for namespace owners managing their personal workspace.
| Uses 'client' middleware group (authenticated, namespace owner).
|
*/
Route::middleware('client')->group(function () {
// Dashboard
Route::get('/dashboard', Dashboard::class)->name('client.dashboard');
// Additional routes can be registered via ClientRoutesRegistering event
});

View file

@ -1,29 +0,0 @@
<?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\Front\Client\View;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* Client dashboard - namespace owner home.
*/
#[Title('Dashboard')]
class Dashboard extends Component
{
public function render(): View
{
return view('client::dashboard')
->layout('client::layouts.app');
}
}

View file

@ -1,78 +0,0 @@
<?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\Service\Concerns;
use Core\Service\Contracts\ServiceDependency;
use Core\Service\ServiceVersion;
/**
* Default implementation of service versioning and dependencies.
*
* Use this trait in ServiceDefinition implementations to provide
* backward-compatible defaults for versioning and dependencies.
* Override version() or dependencies() to customise.
*
* Example:
* ```php
* class MyService implements ServiceDefinition
* {
* use HasServiceVersion;
*
* // Uses default version 1.0.0 and no dependencies
* }
* ```
*
* Or with custom version and dependencies:
* ```php
* class MyService implements ServiceDefinition
* {
* use HasServiceVersion;
*
* public static function version(): ServiceVersion
* {
* return new ServiceVersion(2, 3, 1);
* }
*
* public static function dependencies(): array
* {
* return [
* ServiceDependency::required('auth', '>=1.0.0'),
* ];
* }
* }
* ```
*/
trait HasServiceVersion
{
/**
* Get the service contract version.
*
* Override this method to specify a custom version or deprecation.
*/
public static function version(): ServiceVersion
{
return ServiceVersion::initial();
}
/**
* Get the service dependencies.
*
* Override this method to declare dependencies on other services.
* By default, services have no dependencies.
*
* @return array<ServiceDependency>
*/
public static function dependencies(): array
{
return [];
}
}

View file

@ -1,80 +0,0 @@
<?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\Service\Contracts;
use Core\Service\HealthCheckResult;
/**
* Contract for services that provide health checks.
*
* Services implementing this interface can report their operational status
* for monitoring, load balancing, and alerting purposes. Health endpoints
* can aggregate results from all registered HealthCheckable services.
*
* ## Health Check Guidelines
*
* Health checks should be:
*
* - **Fast** - Complete within 5 seconds (preferably < 1 second)
* - **Non-destructive** - Perform read-only operations only
* - **Representative** - Actually test the critical dependencies
* - **Safe** - Handle all exceptions and return HealthCheckResult
*
* ## Result States
*
* Use `HealthCheckResult` factory methods:
*
* - `healthy()` - Service is fully operational
* - `degraded()` - Service works but with reduced performance/capability
* - `unhealthy()` - Service is not operational
* - `fromException()` - Convert exception to unhealthy result
*
* ## Example Implementation
*
* ```php
* public function healthCheck(): HealthCheckResult
* {
* try {
* $start = microtime(true);
* $this->database->select('SELECT 1');
* $responseTime = (microtime(true) - $start) * 1000;
*
* if ($responseTime > 1000) {
* return HealthCheckResult::degraded(
* 'Database responding slowly',
* ['response_time_ms' => $responseTime]
* );
* }
*
* return HealthCheckResult::healthy(
* 'All systems operational',
* responseTimeMs: $responseTime
* );
* } catch (\Exception $e) {
* return HealthCheckResult::fromException($e);
* }
* }
* ```
*
*
* @see HealthCheckResult For result factory methods
*/
interface HealthCheckable
{
/**
* Perform a health check and return the result.
*
* Implementations should catch all exceptions and return
* an appropriate HealthCheckResult rather than throwing.
*/
public function healthCheck(): HealthCheckResult;
}

View file

@ -1,158 +0,0 @@
<?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\Service\Contracts;
use Core\Front\Admin\Contracts\AdminMenuProvider;
use Core\Service\ServiceVersion;
/**
* Contract for SaaS service definitions.
*
* Services are the product layer of the framework - they define how modules are
* presented to users as SaaS products. Each service has a definition that:
*
* - Populates the `platform_services` table for entitlement management
* - Integrates with the admin menu system via `AdminMenuProvider`
* - Provides versioning for API compatibility and deprecation tracking
*
* ## Service Lifecycle Stages
*
* Services progress through several lifecycle stages:
*
* | Stage | Description | Key Methods |
* |-------|-------------|-------------|
* | Discovery | Service class found in module paths | `definition()` |
* | Validation | Dependencies checked and verified | `dependencies()`, `version()` |
* | Initialization | Service instantiated in correct order | Constructor, DI |
* | Runtime | Service handles requests, provides menu | `menuItems()`, `healthCheck()` |
* | Deprecation | Service marked for removal | `version()->deprecate()` |
* | Sunset | Service no longer available | `version()->isPastSunset()` |
*
* ## Service Definition Array
*
* The `definition()` method returns an array with service metadata:
*
* ```php
* public static function definition(): array
* {
* return [
* 'code' => 'bio', // Unique service code
* 'module' => 'Mod\\Bio', // Module namespace
* 'name' => 'BioHost', // Display name
* 'tagline' => 'Link in bio pages', // Short description
* 'description' => 'Create beautiful...', // Full description
* 'icon' => 'link', // FontAwesome icon
* 'color' => '#3B82F6', // Brand color
* 'entitlement_code' => 'core.srv.bio', // Access control code
* 'sort_order' => 10, // Menu ordering
* ];
* }
* ```
*
* ## Versioning
*
* Services should implement `version()` to declare their contract version.
* This enables tracking breaking changes and deprecation:
*
* ```php
* public static function version(): ServiceVersion
* {
* return new ServiceVersion(2, 1, 0);
* }
*
* // For deprecated services:
* public static function version(): ServiceVersion
* {
* return (new ServiceVersion(1, 0, 0))
* ->deprecate('Use ServiceV2 instead', new \DateTimeImmutable('2025-06-01'));
* }
* ```
*
* ## Health Monitoring
*
* For services that need health monitoring, also implement `HealthCheckable`:
*
* ```php
* class MyService implements ServiceDefinition, HealthCheckable
* {
* public function healthCheck(): HealthCheckResult
* {
* return HealthCheckResult::healthy('All systems operational');
* }
* }
* ```
*
*
* @see AdminMenuProvider For menu integration
* @see ServiceVersion For versioning
* @see ServiceDependency For declaring dependencies
* @see HealthCheckable For health monitoring
* @see ServiceDiscovery For the discovery and resolution process
*/
interface ServiceDefinition extends AdminMenuProvider
{
/**
* Get the service definition for seeding platform_services.
*
* @return array{
* code: string,
* module: string,
* name: string,
* tagline?: string,
* description?: string,
* icon?: string,
* color?: string,
* entitlement_code?: string,
* sort_order?: int,
* }
*/
public static function definition(): array;
/**
* Get the service contract version.
*
* Implementations should return a ServiceVersion indicating the
* current version of this service's contract. This is used for:
* - Compatibility checking between service consumers and providers
* - Deprecation tracking and sunset enforcement
* - Migration planning when breaking changes are introduced
*
* Default implementation returns version 1.0.0 for backward
* compatibility with existing services.
*/
public static function version(): ServiceVersion;
/**
* Get the service dependencies.
*
* Declare other services that this service depends on. The framework
* uses this information to:
* - Validate that required dependencies are available at boot time
* - Resolve services in correct dependency order
* - Detect circular dependencies
*
* ## Example
*
* ```php
* public static function dependencies(): array
* {
* return [
* ServiceDependency::required('auth', '>=1.0.0'),
* ServiceDependency::optional('analytics'),
* ];
* }
* ```
*
* @return array<ServiceDependency>
*/
public static function dependencies(): array;
}

View file

@ -1,150 +0,0 @@
<?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\Service\Contracts;
/**
* Represents a dependency on another service.
*
* Services can declare dependencies on other services using this class,
* enabling the framework to validate service availability, resolve
* dependencies in the correct order, and detect circular dependencies.
*
* ## Dependency Types
*
* - **Required**: Service cannot function without this dependency
* - **Optional**: Service works with reduced functionality if absent
*
* ## Example Usage
*
* ```php
* public static function dependencies(): array
* {
* return [
* // Required dependency on auth service
* ServiceDependency::required('auth', '>=1.0.0'),
*
* // Optional dependency on analytics
* ServiceDependency::optional('analytics'),
*
* // Required with minimum version
* ServiceDependency::required('billing', '>=2.0.0', '<3.0.0'),
* ];
* }
* ```
*/
final readonly class ServiceDependency
{
/**
* @param string $serviceCode The code of the required service
* @param bool $required Whether this dependency is required
* @param string|null $minVersion Minimum version constraint (e.g., ">=1.0.0")
* @param string|null $maxVersion Maximum version constraint (e.g., "<3.0.0")
*/
public function __construct(
public string $serviceCode,
public bool $required = true,
public ?string $minVersion = null,
public ?string $maxVersion = null,
) {}
/**
* Create a required dependency.
*/
public static function required(
string $serviceCode,
?string $minVersion = null,
?string $maxVersion = null
): self {
return new self($serviceCode, true, $minVersion, $maxVersion);
}
/**
* Create an optional dependency.
*/
public static function optional(
string $serviceCode,
?string $minVersion = null,
?string $maxVersion = null
): self {
return new self($serviceCode, false, $minVersion, $maxVersion);
}
/**
* Check if a version satisfies this dependency's constraints.
*/
public function satisfiedBy(string $version): bool
{
if ($this->minVersion !== null && ! $this->checkConstraint($version, $this->minVersion)) {
return false;
}
if ($this->maxVersion !== null && ! $this->checkConstraint($version, $this->maxVersion)) {
return false;
}
return true;
}
/**
* Check a single version constraint.
*/
protected function checkConstraint(string $version, string $constraint): bool
{
// Parse constraint (e.g., ">=1.0.0" or "<3.0.0")
if (preg_match('/^([<>=!]+)?(.+)$/', $constraint, $matches)) {
$operator = $matches[1] ?: '==';
$constraintVersion = ltrim($matches[2], 'v');
$version = ltrim($version, 'v');
return version_compare($version, $constraintVersion, $operator);
}
return true;
}
/**
* Get a human-readable description of the constraint.
*/
public function getConstraintDescription(): string
{
$parts = [];
if ($this->minVersion !== null) {
$parts[] = $this->minVersion;
}
if ($this->maxVersion !== null) {
$parts[] = $this->maxVersion;
}
if (empty($parts)) {
return 'any version';
}
return implode(' ', $parts);
}
/**
* Convert to array for serialization.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return array_filter([
'service_code' => $this->serviceCode,
'required' => $this->required,
'min_version' => $this->minVersion,
'max_version' => $this->maxVersion,
], fn ($v) => $v !== null);
}
}

View file

@ -1,96 +0,0 @@
<?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\Service\Enums;
/**
* Service operational status.
*
* Represents the current state of a service for health monitoring
* and status reporting purposes.
*/
enum ServiceStatus: string
{
case HEALTHY = 'healthy';
case DEGRADED = 'degraded';
case UNHEALTHY = 'unhealthy';
case UNKNOWN = 'unknown';
/**
* Check if the status indicates the service is operational.
*/
public function isOperational(): bool
{
return match ($this) {
self::HEALTHY, self::DEGRADED => true,
self::UNHEALTHY, self::UNKNOWN => false,
};
}
/**
* Get a human-readable label for the status.
*/
public function label(): string
{
return match ($this) {
self::HEALTHY => 'Healthy',
self::DEGRADED => 'Degraded',
self::UNHEALTHY => 'Unhealthy',
self::UNKNOWN => 'Unknown',
};
}
/**
* Get the severity level for logging/alerting.
* Lower values = more severe.
*/
public function severity(): int
{
return match ($this) {
self::HEALTHY => 0,
self::DEGRADED => 1,
self::UNHEALTHY => 2,
self::UNKNOWN => 3,
};
}
/**
* Create status from a boolean health check result.
*/
public static function fromBoolean(bool $healthy): self
{
return $healthy ? self::HEALTHY : self::UNHEALTHY;
}
/**
* Get the worst status from multiple statuses.
*
* @param array<self> $statuses
*/
public static function worst(array $statuses): self
{
if (empty($statuses)) {
return self::UNKNOWN;
}
$worstSeverity = -1;
$worstStatus = self::HEALTHY;
foreach ($statuses as $status) {
if ($status->severity() > $worstSeverity) {
$worstSeverity = $status->severity();
$worstStatus = $status;
}
}
return $worstStatus;
}
}

View file

@ -1,110 +0,0 @@
<?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\Service;
use Core\Service\Enums\ServiceStatus;
/**
* Result of a service health check.
*
* Encapsulates the status, message, and any diagnostic data
* returned from a health check operation.
*/
final readonly class HealthCheckResult
{
/**
* @param array<string, mixed> $data Additional diagnostic data
*/
public function __construct(
public ServiceStatus $status,
public string $message = '',
public array $data = [],
public ?float $responseTimeMs = null,
) {}
/**
* Create a healthy result.
*
* @param array<string, mixed> $data
*/
public static function healthy(string $message = 'Service is healthy', array $data = [], ?float $responseTimeMs = null): self
{
return new self(ServiceStatus::HEALTHY, $message, $data, $responseTimeMs);
}
/**
* Create a degraded result.
*
* @param array<string, mixed> $data
*/
public static function degraded(string $message, array $data = [], ?float $responseTimeMs = null): self
{
return new self(ServiceStatus::DEGRADED, $message, $data, $responseTimeMs);
}
/**
* Create an unhealthy result.
*
* @param array<string, mixed> $data
*/
public static function unhealthy(string $message, array $data = [], ?float $responseTimeMs = null): self
{
return new self(ServiceStatus::UNHEALTHY, $message, $data, $responseTimeMs);
}
/**
* Create an unknown status result.
*
* @param array<string, mixed> $data
*/
public static function unknown(string $message = 'Health check not available', array $data = []): self
{
return new self(ServiceStatus::UNKNOWN, $message, $data);
}
/**
* Create a result from an exception.
*/
public static function fromException(\Throwable $e): self
{
return self::unhealthy(
message: $e->getMessage(),
data: [
'exception' => get_class($e),
'code' => $e->getCode(),
]
);
}
/**
* Check if the result indicates operational status.
*/
public function isOperational(): bool
{
return $this->status->isOperational();
}
/**
* Convert to array for JSON serialization.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return array_filter([
'status' => $this->status->value,
'message' => $this->message,
'data' => $this->data ?: null,
'response_time_ms' => $this->responseTimeMs,
], fn ($v) => $v !== null);
}
}

View file

@ -1,78 +0,0 @@
<?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\Service;
/**
* Exception thrown when service dependencies cannot be resolved.
*
* This exception is thrown in the following scenarios:
*
* - Circular dependency detected between services
* - Required dependency is missing
* - Dependency version constraint not satisfied
*/
class ServiceDependencyException extends \RuntimeException
{
/**
* @var array<string>
*/
protected array $dependencyChain = [];
/**
* Create exception for circular dependency.
*
* @param array<string> $chain The chain of services that form the cycle
*/
public static function circular(array $chain): self
{
$exception = new self(
'Circular dependency detected: '.implode(' -> ', $chain)
);
$exception->dependencyChain = $chain;
return $exception;
}
/**
* Create exception for missing required dependency.
*/
public static function missing(string $service, string $dependency): self
{
return new self(
"Service '{$service}' requires '{$dependency}' which is not available"
);
}
/**
* Create exception for version constraint not satisfied.
*/
public static function versionMismatch(
string $service,
string $dependency,
string $required,
string $available
): self {
return new self(
"Service '{$service}' requires '{$dependency}' {$required}, but {$available} is installed"
);
}
/**
* Get the dependency chain that caused the error.
*
* @return array<string>
*/
public function getDependencyChain(): array
{
return $this->dependencyChain;
}
}

View file

@ -1,748 +0,0 @@
<?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\Service;
use Core\Service\Contracts\ServiceDefinition;
use Core\Service\Contracts\ServiceDependency;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
/**
* Discovers and manages service definitions across the codebase.
*
* Scans configured module paths for classes implementing ServiceDefinition,
* validates dependencies between services, and provides resolution order
* for service initialization.
*
* ## Service Lifecycle Overview
*
* The Core PHP framework manages services through a well-defined lifecycle:
*
* ```
* ┌─────────────────────────────────────────────────────────────────┐
* SERVICE LIFECYCLE
* ├─────────────────────────────────────────────────────────────────┤
*
* 1. DISCOVERY PHASE
* ├── Scan module paths for ServiceDefinition implementations
* ├── Parse definition() arrays to extract service metadata
* └── Build service registry indexed by service code
*
* 2. DEPENDENCY RESOLUTION PHASE
* ├── Collect dependencies() from each service
* ├── Validate required dependencies are available
* ├── Check version constraints are satisfied
* └── Detect and prevent circular dependencies
*
* 3. INITIALIZATION PHASE (dependency order)
* ├── Services initialized in topologically sorted order
* ├── Dependencies always initialized before dependents
* └── Service instances registered in container
*
* 4. RUNTIME PHASE
* ├── Services respond to health checks (HealthCheckable)
* ├── Version() provides compatibility information
* └── Admin menu items rendered (AdminMenuProvider)
*
* 5. DEPRECATION/SUNSET PHASE
* ├── Deprecated services log warnings on access
* ├── Services past sunset date may throw exceptions
* └── Migration to replacement services should occur
*
* └─────────────────────────────────────────────────────────────────┘
* ```
*
* ## Creating a New Service
*
* 1. **Implement ServiceDefinition**: Create a class implementing the interface
* 2. **Define Metadata**: Provide service code, name, description via `definition()`
* 3. **Declare Dependencies**: List required/optional services via `dependencies()`
* 4. **Set Version**: Specify the service contract version via `version()`
* 5. **Add Health Checks**: Implement `HealthCheckable` for monitoring
*
* ```php
* use Core\Service\Contracts\ServiceDefinition;
* use Core\Service\Contracts\HealthCheckable;
* use Core\Service\Concerns\HasServiceVersion;
*
* class BillingService implements ServiceDefinition, HealthCheckable
* {
* use HasServiceVersion;
*
* public static function definition(): array
* {
* return [
* 'code' => 'billing',
* 'module' => 'Mod\\Billing',
* 'name' => 'Billing Service',
* 'tagline' => 'Handle payments and subscriptions',
* 'icon' => 'credit-card',
* 'color' => '#10B981',
* ];
* }
*
* public static function dependencies(): array
* {
* return [
* ServiceDependency::required('auth', '>=1.0.0'),
* ServiceDependency::optional('analytics'),
* ];
* }
*
* public function healthCheck(): HealthCheckResult
* {
* // Check payment provider connectivity
* return HealthCheckResult::healthy();
* }
* }
* ```
*
* ## Discovery Process
*
* 1. Scans module paths for classes implementing ServiceDefinition
* 2. Validates each service's dependencies are available
* 3. Detects circular dependencies
* 4. Returns services in dependency-resolved order
*
* ## Caching
*
* Discovery results are cached for 1 hour (configurable). Clear the cache when
* adding new services or modifying dependencies:
*
* ```php
* $discovery->clearCache();
* ```
*
* Caching can be disabled via config: `core.services.cache_discovery => false`
*
* ## Example Usage
*
* ```php
* $discovery = app(ServiceDiscovery::class);
*
* // Get all registered services
* $services = $discovery->discover();
*
* // Get services in dependency order
* $ordered = $discovery->getResolutionOrder();
*
* // Check if a service is available
* $available = $discovery->has('billing');
*
* // Get a specific service definition class
* $billingClass = $discovery->get('billing');
*
* // Get instantiated service
* $billing = $discovery->getInstance('billing');
*
* // Validate all dependencies are satisfied
* $missing = $discovery->validateDependencies();
* if (!empty($missing)) {
* foreach ($missing as $service => $deps) {
* logger()->error("Service {$service} missing: " . implode(', ', $deps));
* }
* }
* ```
*
*
* @see ServiceDefinition For the service contract interface
* @see ServiceVersion For versioning and deprecation
* @see ServiceDependency For declaring dependencies
* @see HealthCheckable For service health monitoring
*/
class ServiceDiscovery
{
/**
* Cache key for discovered services.
*/
protected const CACHE_KEY = 'core.services.discovered';
/**
* Cache TTL in seconds (1 hour by default).
*/
protected const CACHE_TTL = 3600;
/**
* Discovered service definitions indexed by service code.
*
* @var array<string, class-string<ServiceDefinition>>
*/
protected array $services = [];
/**
* Whether discovery has been performed.
*/
protected bool $discovered = false;
/**
* Additional paths to scan for services.
*
* @var array<string>
*/
protected array $additionalPaths = [];
/**
* Manually registered service classes.
*
* @var array<class-string<ServiceDefinition>>
*/
protected array $registered = [];
/**
* Add a path to scan for service definitions.
*/
public function addPath(string $path): self
{
$this->additionalPaths[] = $path;
$this->discovered = false;
return $this;
}
/**
* Manually register a service definition class.
*
* Validates the service definition before registering. The validation checks:
* - Class exists and implements ServiceDefinition
* - Definition array contains required 'code' field
* - Definition array contains required 'module' field
* - Definition array contains required 'name' field
* - Code is a non-empty string with valid format
*
* @param class-string<ServiceDefinition> $serviceClass
* @param bool $validate Whether to validate the service definition (default: true)
*
* @throws \InvalidArgumentException If validation fails
*/
public function register(string $serviceClass, bool $validate = true): self
{
if ($validate) {
$this->validateServiceClass($serviceClass);
}
$this->registered[] = $serviceClass;
$this->discovered = false;
return $this;
}
/**
* Validate a service definition class.
*
* @param class-string<ServiceDefinition> $serviceClass
*
* @throws \InvalidArgumentException If validation fails
*/
public function validateServiceClass(string $serviceClass): void
{
// Check class exists
if (! class_exists($serviceClass)) {
throw new \InvalidArgumentException(
"Service class '{$serviceClass}' does not exist"
);
}
// Check implements interface
if (! is_subclass_of($serviceClass, ServiceDefinition::class)) {
throw new \InvalidArgumentException(
"Service class '{$serviceClass}' must implement ".ServiceDefinition::class
);
}
// Validate definition array
try {
$definition = $serviceClass::definition();
} catch (\Throwable $e) {
throw new \InvalidArgumentException(
"Service class '{$serviceClass}' definition() method threw an exception: ".$e->getMessage()
);
}
$this->validateDefinitionArray($serviceClass, $definition);
}
/**
* Validate a service definition array.
*
*
* @throws \InvalidArgumentException If validation fails
*/
protected function validateDefinitionArray(string $serviceClass, array $definition): void
{
$requiredFields = ['code', 'module', 'name'];
$missingFields = [];
foreach ($requiredFields as $field) {
if (! isset($definition[$field]) || $definition[$field] === '') {
$missingFields[] = $field;
}
}
if (! empty($missingFields)) {
throw new \InvalidArgumentException(
"Service '{$serviceClass}' definition() is missing required fields: ".implode(', ', $missingFields)
);
}
// Validate code format (should be a simple identifier)
if (! is_string($definition['code'])) {
throw new \InvalidArgumentException(
"Service '{$serviceClass}' code must be a string"
);
}
if (! preg_match('/^[a-z][a-z0-9_-]*$/i', $definition['code'])) {
throw new \InvalidArgumentException(
"Service '{$serviceClass}' code '{$definition['code']}' is invalid. ".
'Code must start with a letter and contain only letters, numbers, underscores, and hyphens.'
);
}
// Validate module namespace format
if (! is_string($definition['module'])) {
throw new \InvalidArgumentException(
"Service '{$serviceClass}' module must be a string"
);
}
// Validate optional fields if present
if (isset($definition['sort_order']) && ! is_int($definition['sort_order'])) {
throw new \InvalidArgumentException(
"Service '{$serviceClass}' sort_order must be an integer"
);
}
if (isset($definition['color']) && ! $this->isValidColor($definition['color'])) {
throw new \InvalidArgumentException(
"Service '{$serviceClass}' color '{$definition['color']}' is invalid. ".
'Color must be a valid hex color (e.g., #3B82F6)'
);
}
}
/**
* Check if a color string is a valid hex color.
*/
protected function isValidColor(mixed $color): bool
{
if (! is_string($color)) {
return false;
}
return (bool) preg_match('/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/', $color);
}
/**
* Validate all registered services and return validation errors.
*
* @return array<string, array<string>> Array of service code => validation errors
*/
public function validateAll(): array
{
$this->discover();
$errors = [];
foreach ($this->services as $code => $class) {
try {
$this->validateServiceClass($class);
} catch (\InvalidArgumentException $e) {
$errors[$code][] = $e->getMessage();
}
// Also validate dependencies
$depErrors = $this->validateServiceDependenciesForService($code, $class);
if (! empty($depErrors)) {
$errors[$code] = array_merge($errors[$code] ?? [], $depErrors);
}
}
return $errors;
}
/**
* Validate dependencies for a specific service.
*
* @param class-string<ServiceDefinition> $class
* @return array<string>
*/
protected function validateServiceDependenciesForService(string $code, string $class): array
{
$errors = [];
$dependencies = $this->getServiceDependencies($class);
foreach ($dependencies as $dependency) {
if ($dependency->required && ! $this->has($dependency->serviceCode)) {
$errors[] = "Required dependency '{$dependency->serviceCode}' is not available";
}
// Check version constraint
if ($this->has($dependency->serviceCode)) {
$depClass = $this->get($dependency->serviceCode);
if ($depClass !== null) {
$version = $depClass::version()->toString();
if (! $dependency->satisfiedBy($version)) {
$errors[] = sprintf(
"Dependency '%s' version mismatch: requires %s, found %s",
$dependency->serviceCode,
$dependency->getConstraintDescription(),
$version
);
}
}
}
}
return $errors;
}
/**
* Discover all service definitions.
*
* @return Collection<string, class-string<ServiceDefinition>>
*/
public function discover(): Collection
{
if ($this->discovered) {
return collect($this->services);
}
// Try to load from cache
$cached = $this->loadFromCache();
if ($cached !== null) {
$this->services = $cached;
$this->discovered = true;
return collect($this->services);
}
// Perform discovery
$this->services = $this->performDiscovery();
$this->discovered = true;
// Cache results
$this->saveToCache($this->services);
return collect($this->services);
}
/**
* Check if a service is available.
*/
public function has(string $serviceCode): bool
{
$this->discover();
return isset($this->services[$serviceCode]);
}
/**
* Get a service definition class by code.
*
* @return class-string<ServiceDefinition>|null
*/
public function get(string $serviceCode): ?string
{
$this->discover();
return $this->services[$serviceCode] ?? null;
}
/**
* Get service instance by code.
*/
public function getInstance(string $serviceCode): ?ServiceDefinition
{
$class = $this->get($serviceCode);
if ($class === null) {
return null;
}
return app($class);
}
/**
* Get services in dependency resolution order.
*
* Services are ordered so that dependencies come before dependents.
*
* @return Collection<int, class-string<ServiceDefinition>>
*
* @throws ServiceDependencyException If circular dependency detected
*/
public function getResolutionOrder(): Collection
{
$this->discover();
$resolved = [];
$resolving = [];
foreach ($this->services as $code => $class) {
$this->resolveService($code, $resolved, $resolving);
}
return collect($resolved);
}
/**
* Validate all service dependencies.
*
* @return array<string, array<string>> Array of service code => missing dependencies
*/
public function validateDependencies(): array
{
$this->discover();
$missing = [];
foreach ($this->services as $code => $class) {
$dependencies = $this->getServiceDependencies($class);
foreach ($dependencies as $dependency) {
if (! $dependency->required) {
continue;
}
if (! $this->has($dependency->serviceCode)) {
$missing[$code][] = $dependency->serviceCode;
continue;
}
// Check version constraint
$depClass = $this->get($dependency->serviceCode);
if ($depClass !== null) {
$version = $depClass::version()->toString();
if (! $dependency->satisfiedBy($version)) {
$missing[$code][] = sprintf(
'%s (%s required, %s available)',
$dependency->serviceCode,
$dependency->getConstraintDescription(),
$version
);
}
}
}
}
return $missing;
}
/**
* Clear the discovery cache.
*/
public function clearCache(): void
{
Cache::forget(self::CACHE_KEY);
$this->discovered = false;
$this->services = [];
}
/**
* Perform the actual discovery process.
*
* @return array<string, class-string<ServiceDefinition>>
*/
protected function performDiscovery(): array
{
$services = [];
// Scan configured module paths
$paths = array_merge(
config('core.module_paths', []),
$this->additionalPaths
);
foreach ($paths as $path) {
if (! is_dir($path)) {
continue;
}
$this->scanPath($path, $services);
}
// Add manually registered services
foreach ($this->registered as $class) {
if (! class_exists($class)) {
continue;
}
if (! is_subclass_of($class, ServiceDefinition::class)) {
continue;
}
$definition = $class::definition();
$code = $definition['code'] ?? null;
if ($code !== null) {
$services[$code] = $class;
}
}
return $services;
}
/**
* Scan a path for service definitions.
*
* @param array<string, class-string<ServiceDefinition>> $services
*/
protected function scanPath(string $path, array &$services): void
{
$files = File::allFiles($path);
foreach ($files as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
// Skip test files
if (str_contains($file->getPathname(), '/Tests/')) {
continue;
}
// Skip vendor directories
if (str_contains($file->getPathname(), '/vendor/')) {
continue;
}
$class = $this->getClassFromFile($file->getPathname());
if ($class === null) {
continue;
}
if (! class_exists($class)) {
continue;
}
if (! is_subclass_of($class, ServiceDefinition::class)) {
continue;
}
try {
$definition = $class::definition();
$code = $definition['code'] ?? null;
if ($code !== null) {
$services[$code] = $class;
}
} catch (\Throwable) {
// Skip services that throw during definition()
}
}
}
/**
* Extract class name from a PHP file.
*/
protected function getClassFromFile(string $filePath): ?string
{
$contents = file_get_contents($filePath);
if ($contents === false) {
return null;
}
$namespace = null;
$class = null;
// Extract namespace
if (preg_match('/namespace\s+([^;]+);/', $contents, $matches)) {
$namespace = $matches[1];
}
// Extract class name
if (preg_match('/class\s+(\w+)/', $contents, $matches)) {
$class = $matches[1];
}
if ($namespace !== null && $class !== null) {
return $namespace.'\\'.$class;
}
return null;
}
/**
* Get dependencies for a service class.
*
* @param class-string<ServiceDefinition> $class
* @return array<ServiceDependency>
*/
protected function getServiceDependencies(string $class): array
{
if (! method_exists($class, 'dependencies')) {
return [];
}
return $class::dependencies();
}
/**
* Resolve a service and its dependencies.
*
* @param array<class-string<ServiceDefinition>> $resolved
* @param array<string> $resolving
*
* @throws ServiceDependencyException If circular dependency detected
*/
protected function resolveService(string $code, array &$resolved, array &$resolving): void
{
if (in_array($code, $resolving)) {
throw new ServiceDependencyException(
'Circular dependency detected: '.implode(' -> ', [...$resolving, $code])
);
}
$class = $this->services[$code] ?? null;
if ($class === null || in_array($class, $resolved)) {
return;
}
$resolving[] = $code;
foreach ($this->getServiceDependencies($class) as $dependency) {
if ($this->has($dependency->serviceCode)) {
$this->resolveService($dependency->serviceCode, $resolved, $resolving);
}
}
array_pop($resolving);
$resolved[] = $class;
}
/**
* Load discovered services from cache.
*
* @return array<string, class-string<ServiceDefinition>>|null
*/
protected function loadFromCache(): ?array
{
if (! config('core.services.cache_discovery', true)) {
return null;
}
return Cache::get(self::CACHE_KEY);
}
/**
* Save discovered services to cache.
*
* @param array<string, class-string<ServiceDefinition>> $services
*/
protected function saveToCache(array $services): void
{
if (! config('core.services.cache_discovery', true)) {
return;
}
Cache::put(self::CACHE_KEY, $services, self::CACHE_TTL);
}
}

View file

@ -1,184 +0,0 @@
<?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\Service;
/**
* Represents a service version with deprecation information.
*
* Follows semantic versioning (major.minor.patch) with support
* for deprecation notices and sunset dates.
*
* ## Semantic Versioning
*
* Versions follow [SemVer](https://semver.org/) conventions:
*
* - **Major**: Breaking changes to the service contract
* - **Minor**: New features, backwards compatible
* - **Patch**: Bug fixes, backwards compatible
*
* ## Lifecycle States
*
* ```
* [Active] ──deprecate()──> [Deprecated] ──isPastSunset()──> [Sunset]
* ```
*
* - **Active**: Service is fully supported
* - **Deprecated**: Service works but consumers should migrate
* - **Sunset**: Service should no longer be used (past sunset date)
*
* ## Usage Examples
*
* ```php
* // Create a version
* $version = new ServiceVersion(2, 1, 0);
* echo $version; // "2.1.0"
*
* // Parse from string
* $version = ServiceVersion::fromString('v2.1.0');
*
* // Mark as deprecated with sunset date
* $version = (new ServiceVersion(1, 0, 0))
* ->deprecate(
* 'Migrate to v2.x - see docs/migration.md',
* new \DateTimeImmutable('2025-06-01')
* );
*
* // Check compatibility
* $minimum = new ServiceVersion(1, 5, 0);
* $current = new ServiceVersion(1, 8, 2);
* $current->isCompatibleWith($minimum); // true (same major, >= minor)
*
* // Check if past sunset
* if ($version->isPastSunset()) {
* throw new ServiceSunsetException('Service no longer available');
* }
* ```
*/
final readonly class ServiceVersion
{
public function __construct(
public int $major,
public int $minor = 0,
public int $patch = 0,
public bool $deprecated = false,
public ?string $deprecationMessage = null,
public ?\DateTimeInterface $sunsetDate = null,
) {}
/**
* Create a version from a string (e.g., "1.2.3").
*/
public static function fromString(string $version): self
{
$parts = explode('.', ltrim($version, 'v'));
return new self(
major: (int) ($parts[0] ?? 1),
minor: (int) ($parts[1] ?? 0),
patch: (int) ($parts[2] ?? 0),
);
}
/**
* Create a version 1.0.0 instance.
*/
public static function initial(): self
{
return new self(1, 0, 0);
}
/**
* Mark this version as deprecated.
*/
public function deprecate(string $message, ?\DateTimeInterface $sunsetDate = null): self
{
return new self(
major: $this->major,
minor: $this->minor,
patch: $this->patch,
deprecated: true,
deprecationMessage: $message,
sunsetDate: $sunsetDate,
);
}
/**
* Check if the version is past its sunset date.
*/
public function isPastSunset(): bool
{
if ($this->sunsetDate === null) {
return false;
}
return $this->sunsetDate < new \DateTimeImmutable;
}
/**
* Compare with another version.
*
* @return int -1 if less, 0 if equal, 1 if greater
*/
public function compare(self $other): int
{
if ($this->major !== $other->major) {
return $this->major <=> $other->major;
}
if ($this->minor !== $other->minor) {
return $this->minor <=> $other->minor;
}
return $this->patch <=> $other->patch;
}
/**
* Check if this version is compatible with a minimum version.
* Compatible if same major version and >= minor.patch.
*/
public function isCompatibleWith(self $minimum): bool
{
if ($this->major !== $minimum->major) {
return false;
}
return $this->compare($minimum) >= 0;
}
/**
* Get version as string.
*/
public function toString(): string
{
return "{$this->major}.{$this->minor}.{$this->patch}";
}
public function __toString(): string
{
return $this->toString();
}
/**
* Convert to array for serialization.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return array_filter([
'version' => $this->toString(),
'deprecated' => $this->deprecated ?: null,
'deprecation_message' => $this->deprecationMessage,
'sunset_date' => $this->sunsetDate?->format('Y-m-d'),
], fn ($v) => $v !== null);
}
}

View file

@ -1,117 +0,0 @@
<?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\Service\Tests\Unit;
use Core\Service\Enums\ServiceStatus;
use Core\Service\HealthCheckResult;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class HealthCheckResultTest extends TestCase
{
#[Test]
public function it_creates_healthy_result(): void
{
$result = HealthCheckResult::healthy('All good', ['key' => 'value'], 15.5);
$this->assertSame(ServiceStatus::HEALTHY, $result->status);
$this->assertSame('All good', $result->message);
$this->assertSame(['key' => 'value'], $result->data);
$this->assertSame(15.5, $result->responseTimeMs);
}
#[Test]
public function it_creates_degraded_result(): void
{
$result = HealthCheckResult::degraded('Slow response');
$this->assertSame(ServiceStatus::DEGRADED, $result->status);
$this->assertSame('Slow response', $result->message);
}
#[Test]
public function it_creates_unhealthy_result(): void
{
$result = HealthCheckResult::unhealthy('Connection failed');
$this->assertSame(ServiceStatus::UNHEALTHY, $result->status);
$this->assertSame('Connection failed', $result->message);
}
#[Test]
public function it_creates_unknown_result(): void
{
$result = HealthCheckResult::unknown();
$this->assertSame(ServiceStatus::UNKNOWN, $result->status);
$this->assertSame('Health check not available', $result->message);
}
#[Test]
public function it_creates_from_exception(): void
{
$exception = new \RuntimeException('Database error', 500);
$result = HealthCheckResult::fromException($exception);
$this->assertSame(ServiceStatus::UNHEALTHY, $result->status);
$this->assertSame('Database error', $result->message);
$this->assertSame('RuntimeException', $result->data['exception']);
$this->assertSame(500, $result->data['code']);
}
#[Test]
public function it_checks_operational_status(): void
{
$this->assertTrue(HealthCheckResult::healthy()->isOperational());
$this->assertTrue(HealthCheckResult::degraded('Slow')->isOperational());
$this->assertFalse(HealthCheckResult::unhealthy('Down')->isOperational());
$this->assertFalse(HealthCheckResult::unknown()->isOperational());
}
#[Test]
public function it_converts_to_array(): void
{
$result = HealthCheckResult::healthy(
'Service operational',
['version' => '1.0'],
12.34
);
$array = $result->toArray();
$this->assertSame('healthy', $array['status']);
$this->assertSame('Service operational', $array['message']);
$this->assertSame(['version' => '1.0'], $array['data']);
$this->assertSame(12.34, $array['response_time_ms']);
}
#[Test]
public function it_omits_null_values_in_array(): void
{
$result = HealthCheckResult::healthy();
$array = $result->toArray();
$this->assertArrayHasKey('status', $array);
$this->assertArrayHasKey('message', $array);
$this->assertArrayNotHasKey('data', $array);
$this->assertArrayNotHasKey('response_time_ms', $array);
}
#[Test]
public function it_uses_default_healthy_message(): void
{
$result = HealthCheckResult::healthy();
$this->assertSame('Service is healthy', $result->message);
}
}

View file

@ -1,102 +0,0 @@
<?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\Service\Tests\Unit;
use Core\Service\Enums\ServiceStatus;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ServiceStatusTest extends TestCase
{
#[Test]
public function it_has_expected_values(): void
{
$this->assertSame('healthy', ServiceStatus::HEALTHY->value);
$this->assertSame('degraded', ServiceStatus::DEGRADED->value);
$this->assertSame('unhealthy', ServiceStatus::UNHEALTHY->value);
$this->assertSame('unknown', ServiceStatus::UNKNOWN->value);
}
#[Test]
public function it_checks_operational_status(): void
{
$this->assertTrue(ServiceStatus::HEALTHY->isOperational());
$this->assertTrue(ServiceStatus::DEGRADED->isOperational());
$this->assertFalse(ServiceStatus::UNHEALTHY->isOperational());
$this->assertFalse(ServiceStatus::UNKNOWN->isOperational());
}
#[Test]
public function it_provides_human_readable_labels(): void
{
$this->assertSame('Healthy', ServiceStatus::HEALTHY->label());
$this->assertSame('Degraded', ServiceStatus::DEGRADED->label());
$this->assertSame('Unhealthy', ServiceStatus::UNHEALTHY->label());
$this->assertSame('Unknown', ServiceStatus::UNKNOWN->label());
}
#[Test]
public function it_has_severity_ordering(): void
{
$this->assertLessThan(
ServiceStatus::DEGRADED->severity(),
ServiceStatus::HEALTHY->severity()
);
$this->assertLessThan(
ServiceStatus::UNHEALTHY->severity(),
ServiceStatus::DEGRADED->severity()
);
$this->assertLessThan(
ServiceStatus::UNKNOWN->severity(),
ServiceStatus::UNHEALTHY->severity()
);
}
#[Test]
public function it_creates_from_boolean(): void
{
$this->assertSame(ServiceStatus::HEALTHY, ServiceStatus::fromBoolean(true));
$this->assertSame(ServiceStatus::UNHEALTHY, ServiceStatus::fromBoolean(false));
}
#[Test]
public function it_finds_worst_status(): void
{
$statuses = [
ServiceStatus::HEALTHY,
ServiceStatus::DEGRADED,
ServiceStatus::HEALTHY,
];
$this->assertSame(ServiceStatus::DEGRADED, ServiceStatus::worst($statuses));
}
#[Test]
public function it_returns_unknown_for_empty_array(): void
{
$this->assertSame(ServiceStatus::UNKNOWN, ServiceStatus::worst([]));
}
#[Test]
public function it_finds_most_severe_status(): void
{
$statuses = [
ServiceStatus::HEALTHY,
ServiceStatus::UNHEALTHY,
ServiceStatus::DEGRADED,
];
$this->assertSame(ServiceStatus::UNHEALTHY, ServiceStatus::worst($statuses));
}
}

View file

@ -1,165 +0,0 @@
<?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\Service\Tests\Unit;
use Core\Service\ServiceVersion;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ServiceVersionTest extends TestCase
{
#[Test]
public function it_creates_version_with_all_components(): void
{
$version = new ServiceVersion(2, 3, 4);
$this->assertSame(2, $version->major);
$this->assertSame(3, $version->minor);
$this->assertSame(4, $version->patch);
$this->assertFalse($version->deprecated);
$this->assertNull($version->deprecationMessage);
$this->assertNull($version->sunsetDate);
}
#[Test]
public function it_creates_initial_version(): void
{
$version = ServiceVersion::initial();
$this->assertSame('1.0.0', $version->toString());
}
#[Test]
public function it_creates_version_from_string(): void
{
$version = ServiceVersion::fromString('2.3.4');
$this->assertSame(2, $version->major);
$this->assertSame(3, $version->minor);
$this->assertSame(4, $version->patch);
}
#[Test]
public function it_handles_string_with_v_prefix(): void
{
$version = ServiceVersion::fromString('v1.2.3');
$this->assertSame('1.2.3', $version->toString());
}
#[Test]
public function it_handles_partial_version_string(): void
{
$version = ServiceVersion::fromString('2');
$this->assertSame(2, $version->major);
$this->assertSame(0, $version->minor);
$this->assertSame(0, $version->patch);
}
#[Test]
public function it_marks_version_as_deprecated(): void
{
$version = new ServiceVersion(1, 0, 0);
$sunset = new \DateTimeImmutable('2025-06-01');
$deprecated = $version->deprecate('Use v2 instead', $sunset);
$this->assertTrue($deprecated->deprecated);
$this->assertSame('Use v2 instead', $deprecated->deprecationMessage);
$this->assertEquals($sunset, $deprecated->sunsetDate);
// Original version should be unchanged
$this->assertFalse($version->deprecated);
}
#[Test]
public function it_detects_past_sunset_date(): void
{
$version = (new ServiceVersion(1, 0, 0))
->deprecate('Old version', new \DateTimeImmutable('2020-01-01'));
$this->assertTrue($version->isPastSunset());
}
#[Test]
public function it_detects_future_sunset_date(): void
{
$version = (new ServiceVersion(1, 0, 0))
->deprecate('Will be removed', new \DateTimeImmutable('2099-01-01'));
$this->assertFalse($version->isPastSunset());
}
#[Test]
public function it_compares_versions_correctly(): void
{
$v1 = new ServiceVersion(1, 0, 0);
$v2 = new ServiceVersion(2, 0, 0);
$v1_1 = new ServiceVersion(1, 1, 0);
$v1_0_1 = new ServiceVersion(1, 0, 1);
$this->assertSame(-1, $v1->compare($v2));
$this->assertSame(1, $v2->compare($v1));
$this->assertSame(0, $v1->compare(new ServiceVersion(1, 0, 0)));
$this->assertSame(-1, $v1->compare($v1_1));
$this->assertSame(-1, $v1->compare($v1_0_1));
}
#[Test]
public function it_checks_compatibility(): void
{
$v2_3 = new ServiceVersion(2, 3, 0);
$v2_1 = new ServiceVersion(2, 1, 0);
$v2_5 = new ServiceVersion(2, 5, 0);
$v3_0 = new ServiceVersion(3, 0, 0);
$this->assertTrue($v2_3->isCompatibleWith($v2_1));
$this->assertFalse($v2_3->isCompatibleWith($v2_5));
$this->assertFalse($v2_3->isCompatibleWith($v3_0));
}
#[Test]
public function it_converts_to_string(): void
{
$version = new ServiceVersion(1, 2, 3);
$this->assertSame('1.2.3', $version->toString());
$this->assertSame('1.2.3', (string) $version);
}
#[Test]
public function it_converts_to_array(): void
{
$version = (new ServiceVersion(1, 2, 3))
->deprecate('Use v2', new \DateTimeImmutable('2025-06-01'));
$array = $version->toArray();
$this->assertSame('1.2.3', $array['version']);
$this->assertTrue($array['deprecated']);
$this->assertSame('Use v2', $array['deprecation_message']);
$this->assertSame('2025-06-01', $array['sunset_date']);
}
#[Test]
public function it_omits_null_values_in_array(): void
{
$version = new ServiceVersion(1, 0, 0);
$array = $version->toArray();
$this->assertArrayHasKey('version', $array);
$this->assertArrayNotHasKey('deprecated', $array);
$this->assertArrayNotHasKey('deprecation_message', $array);
$this->assertArrayNotHasKey('sunset_date', $array);
}
}

View file

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Website\Service;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
/**
* Generic Service Mod.
*
* Serves the public marketing/landing pages for services.
* Uses workspace data (name, icon, color) for dynamic theming.
*
* Services can override this by having their own Mod/{Service} module.
*/
class Boot extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
View::addNamespace('service', __DIR__.'/View/Blade');
Route::middleware('web')
->domain(request()->getHost())
->group(__DIR__.'/Routes/web.php');
}
}

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
use Core\Website\Service\View\Features;
use Core\Website\Service\View\Landing;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Service Mod Routes
|--------------------------------------------------------------------------
|
| Generic marketing/landing pages for services.
| Uses workspace data for dynamic theming based on service color.
|
*/
Route::get('/', Landing::class)->name('service.home');
Route::get('/features', Features::class)->name('service.features');

View file

@ -1,34 +0,0 @@
<footer class="mt-auto">
{{-- Footer gradient border --}}
<div class="h-px w-full" style="background: linear-gradient(to right, transparent, color-mix(in srgb, var(--service-accent) 20%, transparent), 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">
@if(config('core.app.logo') && file_exists(public_path(config('core.app.logo'))))
<img src="/{{ config('core.app.logo') }}" alt="{{ config('core.app.name', 'Service') }}" class="w-6 h-6 opacity-50">
@else
<div class="w-6 h-6 rounded flex items-center justify-center opacity-50" style="background-color: var(--service-accent);">
<i class="fa-solid fa-{{ $workspace['icon'] ?? 'cube' }} text-xs text-slate-900"></i>
</div>
@endif
<span class="text-sm text-slate-500">
&copy; {{ date('Y') }} {{ config('core.app.name', $workspace['name'] ?? 'Service') }}
</span>
</div>
<div class="flex items-center gap-6 text-sm text-slate-500">
@if(config('core.app.privacy_url'))
<a href="{{ config('core.app.privacy_url') }}" class="hover:text-slate-300 transition">Privacy</a>
@endif
@if(config('core.app.terms_url'))
<a href="{{ config('core.app.terms_url') }}" class="hover:text-slate-300 transition">Terms</a>
@endif
@if(config('core.app.powered_by'))
<a href="{{ config('core.app.powered_by_url', '#') }}" class="hover:text-slate-300 transition flex items-center gap-1">
<i class="fa-solid fa-bolt text-xs" style="color: var(--service-accent);"></i>
Powered by {{ config('core.app.powered_by') }}
</a>
@endif
</div>
</div>
</div>
</footer>

View file

@ -1,32 +0,0 @@
<header class="sticky top-0 z-30 bg-slate-900/90 backdrop-blur-xl">
<div class="max-w-5xl mx-auto px-4 sm:px-6">
<div class="flex items-center justify-between h-16">
{{-- Branding --}}
<a href="/" class="flex items-center gap-3 group">
<div class="w-10 h-10 rounded-lg flex items-center justify-center" style="background-color: color-mix(in srgb, var(--service-accent) 20%, transparent);">
<i class="fa-solid fa-{{ $workspace['icon'] ?? 'cube' }}" style="color: var(--service-accent);"></i>
</div>
<span class="font-bold text-lg text-slate-200 group-hover:text-white transition">
{{ $workspace['name'] ?? 'Service' }}
</span>
</a>
{{-- Navigation --}}
<nav class="hidden sm:flex items-center gap-6">
<a href="/" class="text-sm text-slate-400 hover:text-white transition">Home</a>
<a href="/features" class="text-sm text-slate-400 hover:text-white transition">Features</a>
<a href="/pricing" class="text-sm text-slate-400 hover:text-white transition">Pricing</a>
</nav>
{{-- Actions --}}
<div class="flex items-center gap-4">
<a href="{{ config('app.url') }}/login" class="text-sm text-slate-400 hover:text-white transition">Login</a>
<a href="{{ config('app.url') }}/register" class="px-4 py-2 text-sm font-semibold rounded-lg transition" style="background-color: var(--service-accent); color: #1e293b;">
Get started
</a>
</div>
</div>
</div>
{{-- Header gradient border --}}
<div class="h-px w-full" style="background: linear-gradient(to right, transparent, color-mix(in srgb, var(--service-accent) 30%, transparent), transparent);"></div>
</header>

View file

@ -1,92 +0,0 @@
<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

@ -1,76 +0,0 @@
<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-32">
<div class="max-w-3xl">
{{-- 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' }}
</div>
{{-- Headline --}}
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 leading-tight">
{{ $workspace['description'] ?? 'A powerful service' }}
<span style="color: var(--service-accent);">built for creators</span>
</h1>
{{-- Description --}}
<p class="text-lg text-slate-400 mb-8 max-w-xl">
{{ config('core.app.tagline', 'Simple, powerful tools that help you build your online presence.') }}
</p>
{{-- CTAs --}}
<div class="flex flex-col sm:flex-row gap-4">
<a href="{{ config('app.url') }}/register" class="inline-flex items-center justify-center px-8 py-3 font-semibold rounded-xl transition" style="background-color: var(--service-accent); color: var(--service-bg); box-shadow: 0 0 20px color-mix(in srgb, var(--service-accent) 30%, transparent);">
Start free trial
</a>
<a href="/features" class="inline-flex items-center justify-center px-8 py-3 font-semibold rounded-xl transition" style="background-color: color-mix(in srgb, var(--service-accent) 15%, transparent); color: var(--service-accent); border: 1px solid color-mix(in srgb, var(--service-accent) 25%, transparent);">
See all features
</a>
</div>
</div>
</div>
</section>
{{-- Features Section --}}
<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="text-center mb-16">
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">
Everything you need
</h2>
<p class="text-lg text-slate-400 max-w-2xl mx-auto">
Powerful tools built for creators and businesses.
</p>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
@foreach($features as $feature)
<div class="rounded-xl p-6 transition bg-slate-800/50" style="border: 1px solid color-mix(in srgb, var(--service-accent) 15%, transparent);">
<div class="w-12 h-12 rounded-xl flex items-center justify-center mb-4" style="background-color: color-mix(in srgb, var(--service-accent) 15%, transparent);">
<i class="fa-solid fa-{{ $feature['icon'] }} text-lg" style="color: var(--service-accent);"></i>
</div>
<h3 class="text-lg font-semibold text-white mb-2">{{ $feature['title'] }}</h3>
<p class="text-sm text-slate-400">{{ $feature['description'] }}</p>
</div>
@endforeach
</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">
{{ config('core.app.cta_text', 'Join thousands of users building with our platform.') }}
</p>
<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>
</div>
</section>
</div>

View file

@ -1,97 +0,0 @@
@php
$color = $workspace['color'] ?? 'violet';
$colorMap = [
'violet' => ['accent' => '#8b5cf6', 'hover' => '#a78bfa'],
'green' => ['accent' => '#22c55e', 'hover' => '#4ade80'],
'yellow' => ['accent' => '#eab308', 'hover' => '#facc15'],
'orange' => ['accent' => '#f97316', 'hover' => '#fb923c'],
'red' => ['accent' => '#ef4444', 'hover' => '#f87171'],
'cyan' => ['accent' => '#06b6d4', 'hover' => '#22d3ee'],
'blue' => ['accent' => '#3b82f6', 'hover' => '#60a5fa'],
];
$colors = $colorMap[$color] ?? $colorMap['violet'];
@endphp
<!DOCTYPE html>
<html lang="en" class="dark scroll-smooth">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? $workspace['name'] ?? config('core.app.name', 'Service') }}</title>
<meta name="description" content="{{ $workspace['description'] ?? '' }}">
<meta property="og:title" content="{{ $title ?? $workspace['name'] ?? config('core.app.name', 'Service') }}">
<meta property="og:description" content="{{ $workspace['description'] ?? '' }}">
<meta property="og:url" content="{{ request()->url() }}">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
@if(View::exists('layouts::partials.fonts'))
@include('layouts::partials.fonts')
@endif
@if(file_exists(public_path('vendor/fontawesome/css/all.min.css')))
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css">
@endif
@vite(['resources/css/app.css', 'resources/js/app.js'])
@if(class_exists(\Flux\Flux::class))
@fluxAppearance
@endif
<style>
:root {
--service-accent: {{ $colors['accent'] }};
--service-hover: {{ $colors['hover'] }};
}
/* Swing animation for decorative shapes */
@keyframes swing {
0%, 100% { transform: rotate(-3deg); }
50% { transform: rotate(3deg); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
</style>
</head>
<body class="font-sans antialiased bg-slate-900 text-slate-100 tracking-tight min-h-screen flex flex-col overflow-x-hidden overscroll-none">
{{-- Decorative background shapes --}}
<div class="fixed inset-0 -z-10 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-[960px] h-24 top-12 left-1/2 -translate-x-1/2 animate-[swing_8s_ease-in-out_infinite] blur-3xl">
<div class="absolute inset-0 rounded-full -rotate-[42deg]" style="background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--service-accent) 30%, transparent), transparent);"></div>
</div>
<div class="absolute w-[960px] h-24 -top-12 left-1/4 animate-[swing_15s_-1s_ease-in-out_infinite] blur-3xl">
<div class="absolute inset-0 rounded-full -rotate-[42deg]" style="background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--service-hover) 20%, transparent), transparent);"></div>
</div>
<div class="absolute w-[960px] h-64 bottom-24 right-1/4 animate-[swing_10s_ease-in-out_infinite] blur-3xl">
<div class="absolute inset-0 rounded-full -rotate-[42deg]" style="background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--service-accent) 10%, transparent), transparent);"></div>
</div>
</div>
{{-- Header --}}
@include('service::components.header', ['workspace' => $workspace, 'colors' => $colors])
{{-- Main Content --}}
<main class="flex-1 relative">
{{-- Radial gradient glow at top of content --}}
<div class="absolute flex items-center justify-center top-0 -translate-y-1/2 left-1/2 -translate-x-1/2 pointer-events-none -z-10 w-[800px] aspect-square" aria-hidden="true">
<div class="absolute inset-0 translate-z-0 rounded-full blur-[120px] opacity-20" style="background-color: var(--service-accent);"></div>
<div class="absolute w-64 h-64 translate-z-0 rounded-full blur-[80px] opacity-40" style="background-color: var(--service-hover);"></div>
</div>
{{ $slot }}
</main>
{{-- Footer --}}
@include('service::components.footer', ['workspace' => $workspace, 'colors' => $colors])
@if(class_exists(\Flux\Flux::class))
@fluxScripts
@endif
{{ $scripts ?? '' }}
@stack('scripts')
</body>
</html>

View file

@ -1,163 +0,0 @@
<?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,
]);
}
}

View file

@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Website\Service\View;
use Illuminate\Contracts\View\View;
use Livewire\Component;
/**
* Generic Service Landing Page.
*
* Public landing page for services.
* Uses workspace data for dynamic theming.
*/
class Landing 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
{
// Handle patterns like social.host.test, social.host.uk.com
if (preg_match('/^([a-z]+)\.host\.(test|localhost|uk\.com)$/i', $host, $matches)) {
return $matches[1];
}
return null;
}
/**
* Get features for the service.
*/
public function getFeatures(): array
{
// Generic features - services can override in their own Landing
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.',
],
];
}
public function render(): View
{
$appName = config('core.app.name', config('app.name', 'Service'));
return view('service::landing', [
'workspace' => $this->workspace,
'features' => $this->getFeatures(),
])->layout('service::layouts.service', [
'title' => $this->workspace['name'] !== $appName
? $this->workspace['name'].' - '.$appName
: $appName,
'workspace' => $this->workspace,
]);
}
}