From 2dc7bcd3e6415e15f1afb24da131b8d7abddceb3 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 18:55:24 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20core/php-service=20=E2=80=94?= =?UTF-8?q?=20service=20discovery=20+=20landing=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts Core\Service (discovery, dependencies, health) and Core\Website\Service (marketing landing pages) from core/php. Co-Authored-By: Claude Opus 4.6 --- LICENSE | 22 + composer.json | 25 + .../Service/Concerns/HasServiceVersion.php | 78 ++ .../Service/Contracts/HealthCheckable.php | 80 ++ .../Service/Contracts/ServiceDefinition.php | 158 ++++ .../Service/Contracts/ServiceDependency.php | 150 ++++ src/Core/Service/Enums/ServiceStatus.php | 96 +++ src/Core/Service/HealthCheckResult.php | 110 +++ .../Service/ServiceDependencyException.php | 78 ++ src/Core/Service/ServiceDiscovery.php | 748 ++++++++++++++++++ src/Core/Service/ServiceVersion.php | 184 +++++ .../Tests/Unit/HealthCheckResultTest.php | 117 +++ .../Service/Tests/Unit/ServiceStatusTest.php | 102 +++ .../Service/Tests/Unit/ServiceVersionTest.php | 165 ++++ src/Website/Service/Boot.php | 34 + src/Website/Service/Routes/web.php | 20 + .../View/Blade/components/footer.blade.php | 34 + .../View/Blade/components/header.blade.php | 32 + .../Service/View/Blade/features.blade.php | 92 +++ .../Service/View/Blade/landing.blade.php | 76 ++ .../View/Blade/layouts/service.blade.php | 97 +++ src/Website/Service/View/Features.php | 163 ++++ src/Website/Service/View/Landing.php | 100 +++ 23 files changed, 2761 insertions(+) create mode 100644 LICENSE create mode 100644 composer.json create mode 100644 src/Core/Service/Concerns/HasServiceVersion.php create mode 100644 src/Core/Service/Contracts/HealthCheckable.php create mode 100644 src/Core/Service/Contracts/ServiceDefinition.php create mode 100644 src/Core/Service/Contracts/ServiceDependency.php create mode 100644 src/Core/Service/Enums/ServiceStatus.php create mode 100644 src/Core/Service/HealthCheckResult.php create mode 100644 src/Core/Service/ServiceDependencyException.php create mode 100644 src/Core/Service/ServiceDiscovery.php create mode 100644 src/Core/Service/ServiceVersion.php create mode 100644 src/Core/Service/Tests/Unit/HealthCheckResultTest.php create mode 100644 src/Core/Service/Tests/Unit/ServiceStatusTest.php create mode 100644 src/Core/Service/Tests/Unit/ServiceVersionTest.php create mode 100644 src/Website/Service/Boot.php create mode 100644 src/Website/Service/Routes/web.php create mode 100644 src/Website/Service/View/Blade/components/footer.blade.php create mode 100644 src/Website/Service/View/Blade/components/header.blade.php create mode 100644 src/Website/Service/View/Blade/features.blade.php create mode 100644 src/Website/Service/View/Blade/landing.blade.php create mode 100644 src/Website/Service/View/Blade/layouts/service.blade.php create mode 100644 src/Website/Service/View/Features.php create mode 100644 src/Website/Service/View/Landing.php diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2bbdfcb --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +European Union Public Licence +Version 1.2 + +EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the 'EUPL') applies to the Work (as +defined below) which is provided under the terms of this Licence. Any use +of the Work, other than as authorised under this Licence is prohibited (to +the extent such use is covered by a right of the copyright holder of the +Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the +EUPL. + +For the full licence text, see: +https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b0cf4b7 --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "name": "lthn/service", + "description": "SaaS service discovery, dependency resolution, and landing pages", + "license": "EUPL-1.2", + "require": { + "php": "^8.2", + "lthn/php": "*" + }, + "autoload": { + "psr-4": { + "Core\\Service\\": "src/Core/Service/", + "Core\\Website\\Service\\": "src/Website/Service/" + } + }, + "replace": { + "core/php-service": "self.version" + }, + "extra": { + "laravel": { + "dont-discover": [] + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/src/Core/Service/Concerns/HasServiceVersion.php b/src/Core/Service/Concerns/HasServiceVersion.php new file mode 100644 index 0000000..7f061e0 --- /dev/null +++ b/src/Core/Service/Concerns/HasServiceVersion.php @@ -0,0 +1,78 @@ +=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 + */ + public static function dependencies(): array + { + return []; + } +} diff --git a/src/Core/Service/Contracts/HealthCheckable.php b/src/Core/Service/Contracts/HealthCheckable.php new file mode 100644 index 0000000..fd37836 --- /dev/null +++ b/src/Core/Service/Contracts/HealthCheckable.php @@ -0,0 +1,80 @@ +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; +} diff --git a/src/Core/Service/Contracts/ServiceDefinition.php b/src/Core/Service/Contracts/ServiceDefinition.php new file mode 100644 index 0000000..715a32e --- /dev/null +++ b/src/Core/Service/Contracts/ServiceDefinition.php @@ -0,0 +1,158 @@ +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 + */ + public static function dependencies(): array; +} diff --git a/src/Core/Service/Contracts/ServiceDependency.php b/src/Core/Service/Contracts/ServiceDependency.php new file mode 100644 index 0000000..296eaba --- /dev/null +++ b/src/Core/Service/Contracts/ServiceDependency.php @@ -0,0 +1,150 @@ +=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 + */ + 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); + } +} diff --git a/src/Core/Service/Enums/ServiceStatus.php b/src/Core/Service/Enums/ServiceStatus.php new file mode 100644 index 0000000..a5bf9ea --- /dev/null +++ b/src/Core/Service/Enums/ServiceStatus.php @@ -0,0 +1,96 @@ + 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 $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; + } +} diff --git a/src/Core/Service/HealthCheckResult.php b/src/Core/Service/HealthCheckResult.php new file mode 100644 index 0000000..6f044f3 --- /dev/null +++ b/src/Core/Service/HealthCheckResult.php @@ -0,0 +1,110 @@ + $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 $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 $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 $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 $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 + */ + 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); + } +} diff --git a/src/Core/Service/ServiceDependencyException.php b/src/Core/Service/ServiceDependencyException.php new file mode 100644 index 0000000..19d21c7 --- /dev/null +++ b/src/Core/Service/ServiceDependencyException.php @@ -0,0 +1,78 @@ + + */ + protected array $dependencyChain = []; + + /** + * Create exception for circular dependency. + * + * @param array $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 + */ + public function getDependencyChain(): array + { + return $this->dependencyChain; + } +} diff --git a/src/Core/Service/ServiceDiscovery.php b/src/Core/Service/ServiceDiscovery.php new file mode 100644 index 0000000..e2e03f8 --- /dev/null +++ b/src/Core/Service/ServiceDiscovery.php @@ -0,0 +1,748 @@ + '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> + */ + protected array $services = []; + + /** + * Whether discovery has been performed. + */ + protected bool $discovered = false; + + /** + * Additional paths to scan for services. + * + * @var array + */ + protected array $additionalPaths = []; + + /** + * Manually registered service classes. + * + * @var array> + */ + 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 $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 $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> 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 $class + * @return array + */ + 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> + */ + 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|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> + * + * @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> 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> + */ + 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> $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 $class + * @return array + */ + protected function getServiceDependencies(string $class): array + { + if (! method_exists($class, 'dependencies')) { + return []; + } + + return $class::dependencies(); + } + + /** + * Resolve a service and its dependencies. + * + * @param array> $resolved + * @param array $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>|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> $services + */ + protected function saveToCache(array $services): void + { + if (! config('core.services.cache_discovery', true)) { + return; + } + + Cache::put(self::CACHE_KEY, $services, self::CACHE_TTL); + } +} diff --git a/src/Core/Service/ServiceVersion.php b/src/Core/Service/ServiceVersion.php new file mode 100644 index 0000000..59a2b4d --- /dev/null +++ b/src/Core/Service/ServiceVersion.php @@ -0,0 +1,184 @@ + [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 + */ + 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); + } +} diff --git a/src/Core/Service/Tests/Unit/HealthCheckResultTest.php b/src/Core/Service/Tests/Unit/HealthCheckResultTest.php new file mode 100644 index 0000000..3e005a2 --- /dev/null +++ b/src/Core/Service/Tests/Unit/HealthCheckResultTest.php @@ -0,0 +1,117 @@ + '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); + } +} diff --git a/src/Core/Service/Tests/Unit/ServiceStatusTest.php b/src/Core/Service/Tests/Unit/ServiceStatusTest.php new file mode 100644 index 0000000..561ff73 --- /dev/null +++ b/src/Core/Service/Tests/Unit/ServiceStatusTest.php @@ -0,0 +1,102 @@ +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)); + } +} diff --git a/src/Core/Service/Tests/Unit/ServiceVersionTest.php b/src/Core/Service/Tests/Unit/ServiceVersionTest.php new file mode 100644 index 0000000..d202fbe --- /dev/null +++ b/src/Core/Service/Tests/Unit/ServiceVersionTest.php @@ -0,0 +1,165 @@ +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); + } +} diff --git a/src/Website/Service/Boot.php b/src/Website/Service/Boot.php new file mode 100644 index 0000000..f5a78e3 --- /dev/null +++ b/src/Website/Service/Boot.php @@ -0,0 +1,34 @@ +domain(request()->getHost()) + ->group(__DIR__.'/Routes/web.php'); + } +} diff --git a/src/Website/Service/Routes/web.php b/src/Website/Service/Routes/web.php new file mode 100644 index 0000000..c8a68fb --- /dev/null +++ b/src/Website/Service/Routes/web.php @@ -0,0 +1,20 @@ +name('service.home'); +Route::get('/features', Features::class)->name('service.features'); diff --git a/src/Website/Service/View/Blade/components/footer.blade.php b/src/Website/Service/View/Blade/components/footer.blade.php new file mode 100644 index 0000000..a13fdd3 --- /dev/null +++ b/src/Website/Service/View/Blade/components/footer.blade.php @@ -0,0 +1,34 @@ +
+ {{-- Footer gradient border --}} +
+
+
+
+ @if(config('core.app.logo') && file_exists(public_path(config('core.app.logo')))) + {{ config('core.app.name', 'Service') }} + @else +
+ +
+ @endif + + © {{ date('Y') }} {{ config('core.app.name', $workspace['name'] ?? 'Service') }} + +
+
+ @if(config('core.app.privacy_url')) + Privacy + @endif + @if(config('core.app.terms_url')) + Terms + @endif + @if(config('core.app.powered_by')) + + + Powered by {{ config('core.app.powered_by') }} + + @endif +
+
+
+
diff --git a/src/Website/Service/View/Blade/components/header.blade.php b/src/Website/Service/View/Blade/components/header.blade.php new file mode 100644 index 0000000..6e02b07 --- /dev/null +++ b/src/Website/Service/View/Blade/components/header.blade.php @@ -0,0 +1,32 @@ +
+
+
+ {{-- Branding --}} + +
+ +
+ + {{ $workspace['name'] ?? 'Service' }} + +
+ + {{-- Navigation --}} + + + {{-- Actions --}} + +
+
+ {{-- Header gradient border --}} +
+
diff --git a/src/Website/Service/View/Blade/features.blade.php b/src/Website/Service/View/Blade/features.blade.php new file mode 100644 index 0000000..312a72e --- /dev/null +++ b/src/Website/Service/View/Blade/features.blade.php @@ -0,0 +1,92 @@ +
+ {{-- Hero Section --}} +
+
+
+ {{-- Badge --}} +
+ + {{ $workspace['name'] ?? 'Service' }} Features +
+ + {{-- Headline --}} +

+ Everything you need to + succeed +

+ + {{-- Description --}} +

+ {{ $workspace['description'] ?? 'Powerful features built for creators and businesses.' }} +

+
+
+
+ + {{-- Features Grid --}} +
+
+
+ @foreach($features as $feature) +
+
+ +
+

{{ $feature['title'] }}

+

{{ $feature['description'] }}

+
+ @endforeach +
+
+
+ + {{-- Integration Section --}} +
+
+

+ Part of the Host UK ecosystem +

+

+ {{ $workspace['name'] ?? 'This service' }} works seamlessly with other Host UK services. + One account, unified billing, integrated tools. +

+ +
+
+ + {{-- CTA Section --}} +
+
+

+ Ready to get started? +

+

+ Start your free trial today. No credit card required. +

+ +
+
+
diff --git a/src/Website/Service/View/Blade/landing.blade.php b/src/Website/Service/View/Blade/landing.blade.php new file mode 100644 index 0000000..10f34a0 --- /dev/null +++ b/src/Website/Service/View/Blade/landing.blade.php @@ -0,0 +1,76 @@ +
+ {{-- Hero Section --}} +
+
+
+ {{-- Badge --}} +
+ + {{ $workspace['name'] ?? 'Service' }} +
+ + {{-- Headline --}} +

+ {{ $workspace['description'] ?? 'A powerful service' }} + built for creators +

+ + {{-- Description --}} +

+ {{ config('core.app.tagline', 'Simple, powerful tools that help you build your online presence.') }} +

+ + {{-- CTAs --}} + +
+
+
+ + {{-- Features Section --}} +
+
+
+

+ Everything you need +

+

+ Powerful tools built for creators and businesses. +

+
+ +
+ @foreach($features as $feature) +
+
+ +
+

{{ $feature['title'] }}

+

{{ $feature['description'] }}

+
+ @endforeach +
+
+
+ + {{-- CTA Section --}} +
+
+

+ Ready to get started? +

+

+ {{ config('core.app.cta_text', 'Join thousands of users building with our platform.') }} +

+ + Get started free + +
+
+
diff --git a/src/Website/Service/View/Blade/layouts/service.blade.php b/src/Website/Service/View/Blade/layouts/service.blade.php new file mode 100644 index 0000000..4eaa4a8 --- /dev/null +++ b/src/Website/Service/View/Blade/layouts/service.blade.php @@ -0,0 +1,97 @@ +@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 + + + + + + + + {{ $title ?? $workspace['name'] ?? config('core.app.name', 'Service') }} + + + + + + + + + @if(View::exists('layouts::partials.fonts')) + @include('layouts::partials.fonts') + @endif + @if(file_exists(public_path('vendor/fontawesome/css/all.min.css'))) + + @endif + @vite(['resources/css/app.css', 'resources/js/app.js']) + @if(class_exists(\Flux\Flux::class)) + @fluxAppearance + @endif + + + + + + {{-- Decorative background shapes --}} + + + {{-- Header --}} + @include('service::components.header', ['workspace' => $workspace, 'colors' => $colors]) + + {{-- Main Content --}} +
+ {{-- Radial gradient glow at top of content --}} + + + {{ $slot }} +
+ + {{-- Footer --}} + @include('service::components.footer', ['workspace' => $workspace, 'colors' => $colors]) + + @if(class_exists(\Flux\Flux::class)) + @fluxScripts + @endif + + {{ $scripts ?? '' }} + + @stack('scripts') + + diff --git a/src/Website/Service/View/Features.php b/src/Website/Service/View/Features.php new file mode 100644 index 0000000..a626ddb --- /dev/null +++ b/src/Website/Service/View/Features.php @@ -0,0 +1,163 @@ +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, + ]); + } +} diff --git a/src/Website/Service/View/Landing.php b/src/Website/Service/View/Landing.php new file mode 100644 index 0000000..455aa72 --- /dev/null +++ b/src/Website/Service/View/Landing.php @@ -0,0 +1,100 @@ +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, + ]); + } +}