refactor: extract Service + Client to standalone packages
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:
parent
dc064cbb61
commit
affedb3d46
27 changed files with 0 additions and 3003 deletions
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 />
|
||||
```
|
||||
|
|
@ -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
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
@ -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">
|
||||
© {{ 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue